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 @MainActor just 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.

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.