How to Fix Swift Concurrency Data Races Without Freezing Your UI
Today I want to walk through a problem that shows up in a lot of Swift apps once strict concurrency checks get real: a service works fine at first, then warnings start piling up because non-Sendable state is quietly moving across task boundaries.
This usually happens in networking, connectivity, media, or device-sync code. The app still seems to run, but the architecture starts lying to you. The compiler is telling you that the ownership model is blurry.
The fix is not to throw @MainActor on everything and hope for the best. The better move is to decide which objects are allowed to cross concurrency boundaries, and which ones need a handoff point.
Where the race usually starts
A common pattern looks harmless:
final class SessionCoordinator {
private var socket: LegacySocket
init(socket: LegacySocket) {
self.socket = socket
}
func reconnect() {
Task {
try await socket.open()
}
}
}
This is exactly the kind of code that turns into trouble when LegacySocket is not Sendable. The Task closure may run on a different executor, and now a shared reference type is crossing a boundary with no protection.
Step 1: Stop sending the unsafe object across the boundary
Instead of letting the task capture the non-Sendable object directly, move the work behind a single coordination queue or actor-like handoff.
import Foundation
final class ReconnectBridge {
private let queue = DispatchQueue(label: "session.reconnect")
private let socket: LegacySocket
init(socket: LegacySocket) {
self.socket = socket
}
func reconnect() {
queue.async {
self.socket.openSynchronously()
}
}
}
This is not fancy, but it is honest. The socket stays behind one execution path, and the rest of the app stops pretending the object is safe to move anywhere.
Step 2: Add an async wrapper for the outside world
The outside API can still feel modern without leaking unsafe state.
final class SessionCoordinator {
private let bridge: ReconnectBridge
init(bridge: ReconnectBridge) {
self.bridge = bridge
}
func reconnect() async {
await withCheckedContinuation { continuation in
bridge.reconnect()
continuation.resume()
}
}
}
In a real system you would resume on completion or error, but the important part is the shape: the unsafe object stays boxed in, and the public API stays predictable.
Step 3: Separate UI updates from service mutations
Another source of race warnings is mixing mutation and presentation in the same async flow.
A safer pattern is:
- service layer owns connection state changes
- view model observes clean snapshots
- UI reacts on the main actor only
@MainActor
final class SessionViewModel: ObservableObject {
@Published private(set) var statusText = "Idle"
func apply(_ state: ConnectionState) {
switch state {
case .connected:
statusText = "Connected"
case .connecting:
statusText = "Connecting"
case .disconnected:
statusText = "Disconnected"
}
}
}
The rule here is simple: the UI should receive values, not half-managed service objects.
A useful mental model
Think of a non-Sendable type like a physical notebook on one desk. If two people keep grabbing it from different rooms, pages get lost. The answer is not to move faster. The answer is to define one place where edits happen, then send copies of the information outward.
That is what a dispatch helper, bridge, or actor boundary is doing. It creates one desk.
Pitfalls that make the warning come back
- Capturing a stored service object inside
Task {}out of convenience. - Marking a whole class
@MainActorjust to silence warnings in non-UI code. - Returning mutable reference types from your service layer.
- Mixing deployment-target cleanup and concurrency cleanup without retesting both paths.
What to verify after the fix
Run through a short checklist:
- connection or sync events still arrive in the right order
- repeated reconnect attempts do not overlap incorrectly
- UI updates only happen on the main actor
- strict concurrency warnings stay gone after a clean build
- older deployment targets still behave correctly if you changed platform minimums
Once these are stable, the codebase gets easier to trust. That is the real win. Strict concurrency is not just about making warnings disappear. It is about making ownership obvious enough that the next feature does not reopen the same bug.