The Race Condition You Can't See
Data races have haunted Swift development since concurrency arrived in iOS 13. You ship an app, users report crashes on background syncs, and your logs give you nothing. Swift 6 strict concurrency changes that equation entirely—the compiler now catches data races at compile time, before your code ships. This article walks through Swift 6 strict concurrency: patterns, pitfalls and fixes, showing you how to enable the checks, fix warnings gradually, architect your types for thread safety, and avoid the common pitfalls our teams have hit in production.
Enable Complete Concurrency Checking
The first step is switching on the strictest compiler mode. In Xcode, select your target, go to Build Settings, search for "concurrency", and set Complete Concurrency Checking to Strict. This tells Swift to validate that all access to shared mutable state is protected by isolation mechanisms.
When you flip this switch on an existing codebase, you'll likely see hundreds of warnings. That's normal. The warnings are telling you where data races could happen—places where multiple threads (or tasks) could read or write the same value simultaneously without synchronization.
Here's what a typical warning looks like:
class UserRepository {
var cachedUsers: [User] = [] // ⚠️ Main actor-isolated property can not be mutated from a non-isolated context
func fetchUser(id: Int) async {
// called from a background task
cachedUsers.append(user) // ⚠️ Mutating access requires main thread
}
}
The compiler is right. If fetchUser runs on a background thread and tries to mutate cachedUsers while the UI thread is reading it, you have a race. Swift 6 catches this statically instead of letting it blow up at runtime.
Start by enabling strict checking on one target at a time, not your whole project. Fix that target, commit, then move on. Rushing through all the warnings in one session leads to bugs.
Fix Sendable Warnings Gradually
At the heart of Swift's concurrency model is the concept of Sendable. A type is Sendable if it's safe to pass between isolation domains—like from a background task to the main thread, or between actors. Value types (structs, enums) are implicitly Sendable. Reference types (classes) are not, unless you explicitly mark them and prove it to the compiler.
When the compiler sees you passing a non-Sendable class across a boundary, it warns you. Here's a real scenario from a ride-hailing backend we worked on:
class LocationUpdate {
var latitude: Double
var longitude: Double
var timestamp: Date
init(lat: Double, lng: Double) {
self.latitude = lat
self.longitude = lng
self.timestamp = Date()
}
}
Task {
let update = LocationUpdate(lat: 37.7749, lng: -122.4194)
try await locationService.report(update) // ⚠️ Passing non-Sendable across isolation boundary
}
The fix depends on your type. If it's truly immutable, mark it Sendable and make all properties let:
final class LocationUpdate: Sendable {
let latitude: Double
let longitude: Double
let timestamp: Date
init(lat: Double, lng: Double) {
self.latitude = lat
self.longitude = lng
self.timestamp = Date()
}
}
If your type needs to be mutable and shared, don't mark it Sendable. Instead, wrap it in an actor (covered below). If it's a value type already, you get Sendable for free:
struct LocationUpdate: Sendable {
let latitude: Double
let longitude: Double
let timestamp: Date
}
Don't rush to mark everything Sendable. Each one is a promise: "this type is safe to share across threads." If you break that promise, you've just introduced a data race. Be conservative. If you're unsure, don't mark it Sendable yet.
Mark UI Types @MainActor
UIKit and SwiftUI components must run on the main thread. Swift lets you declare that explicitly with the @MainActor annotation. This tells the compiler: "all code in this type runs on the main thread, so you don't need to synchronize access."
Apply @MainActor to view controllers, view models, and any type that owns UI state:
@MainActor
final class OrderViewController: UIViewController {
var order: Order?
var button: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
// This runs on main, guaranteed
}
func updateUI(with order: Order) {
self.order = order
button.setTitle(order.id, for: .normal)
}
}
Now, if you try to call updateUI from a background task, the compiler stops you:
Task.detached {
let order = try await fetchOrderFromAPI()
viewController.updateUI(with: order) // ⚠️ Call to main actor-isolated instance from non-isolated context
}
The fix is explicit: hop back to the main thread with @MainActor.run or DispatchQueue.main:
Task.detached {
let order = try await fetchOrderFromAPI()
await MainActor.run {
viewController.updateUI(with: order)
}
}
Or use a structured task that respects the calling context:
Task {
let order = try await fetchOrderFromAPI()
viewController.updateUI(with: order) // ✓ No warning; this task runs on main
}
For SwiftUI, mark your @Observable view models @MainActor as well:
@MainActor
@Observable
final class OrderVM {
var order: Order?
var loading = false
func load() async {
loading = true
defer { loading = false }
order = try? await fetchOrder()
}
}
Use Actors for Shared State
Actors are the workhorse of Swift concurrency. An actor serializes access to its mutable state—only one task can run an actor method at a time. Use them for any shared, mutable resource: caches, databases, sockets, device state.
Here's an actor from a trading backend we worked on, managing a live quote cache:
actor QuoteCache {
private var quotes: [String: Quote] = [:]
func getQuote(symbol: String) -> Quote? {
quotes[symbol]
}
func setQuote(_ quote: Quote, for symbol: String) {
quotes[symbol] = quote
}
func update(from feed: QuoteFeed) async {
for quote in feed.quotes {
quotes[quote.symbol] = quote
}
}
}
let cache = QuoteCache()
// Multiple tasks can safely call this concurrently
Task { await cache.setQuote(quote1, for: "AAPL") }
Task { await cache.setQuote(quote2, for: "MSFT") }
Task { let q = await cache.getQuote(symbol: "AAPL") }
Each await point is a checkpoint: the task yields to let other tasks use the actor. If you call a property on an actor, you need to await:
// Compiler error
let count = cache.quotes.count
// Correct
let quote = await cache.getQuote(symbol: "AAPL")
Actor properties are isolated. You can't read them directly outside the actor without going through a method. This forces you to think about concurrency explicitly. That's the point.
If you have a shared resource that's truly immutable, you don't need an actor. Just ensure it's Sendable:
let appConfig = AppConfig() // immutable, Sendable
Task.detached {
print(appConfig.apiURL) // ✓ Safe to read from any thread
}
Common Mistakes
- Sprinkling @MainActor on everything. Only mark types that genuinely must run on the main thread. Overusing it defeats the purpose of concurrency.
- Marking a mutable class Sendable without synchronization. This is a lie to the compiler. The class will still race. Use an actor instead.
- Ignoring warnings and disabling strict checking. The warnings exist because the compiler found a real race condition. Fix the root cause, not the symptom.
- Assuming all value types are safe. They are thread-safe, but if they contain references to non-
Sendableclasses, you still have problems. - Not testing on devices. Strict concurrency catches many races at compile time, but not all. Still test on real hardware under load.
Key Takeaways
- Enable Complete Concurrency Checking: Strict in Build Settings to catch data races at compile time.
- Fix
Sendablewarnings gradually by making types immutable and adding theSendableconformance, or by wrapping mutable types in actors. - Mark UI types with
@MainActorto guarantee main-thread execution and eliminate synchronization overhead. - Use actors for shared, mutable state—they serialize access and make concurrency safe and explicit.
- Treat compiler warnings as bugs, not inconveniences. Swift 6's strictness catches real race conditions before users see them.






Comments
Loading comments…