Swift Concurrency in Production: What the WWDC Demos Skip

What Swift Concurrency actually demands in a production codebase: cancellation, actor reentrancy, and Swift 6 migration without breaking ship dates.

The crash logs showed nothing. The user tapped a row, navigated back, tapped another — and somewhere between those taps a Task was overwriting a property that had already been published to a new screen. No exception. No console warning. Just a stale UI that did not match the data underneath it.

That bug took three days to find, and it lives in the gap between Swift Concurrency’s documentation and what you actually need when async/await meets a real codebase.

Why this matters

Swift 6 mode is now the default for new projects in Xcode 16, and strict concurrency checking is the part of it that bites hardest. If your codebase predates async/await — most enterprise iOS codebases do — you are about to inherit somewhere between fifty and two thousand compiler warnings, and you cannot ignore them indefinitely. The runtime semantics changed underneath you when you adopted actors. The compiler is now telling you what changed.

I migrated the network layer of a large-scale production iOS app from completion handlers to async/await across two releases. The first release was the easy half: replace the callbacks, keep the existing call sites. The second release was the part nobody warns you about — the part where the migration is “done” but production is misbehaving in ways the unit tests cannot reproduce.

This post is the field guide I wish someone had handed me on day one of that migration. Four real patterns, the gotchas they exist to absorb, and the Swift 6 compiler errors you are going to see next sprint.

The problem

Here is the kind of code most iOS apps still ship in 2026:

// Pre-concurrency view model — completion handlers with implicit ordering
final class FeedViewModel {
    private let service: FeedService
    private var inFlight: URLSessionDataTask?
    @Published var items: [FeedItem] = []

    func refresh() {
        inFlight?.cancel()
        inFlight = service.loadFeed { [weak self] result in
            DispatchQueue.main.async {
                guard let self else { return }
                if case .success(let items) = result {
                    self.items = items
                }
            }
        }
    }
}

That works. It has been in production for six years. Now you rewrite it to async/await because the team agreed at the last architecture review:

// First pass at the async migration — looks clean, has a subtle bug
@MainActor
final class FeedViewModel: ObservableObject {
    private let service: FeedService
    @Published var items: [FeedItem] = []

    func refresh() async {
        let items = try? await service.loadFeed()
        self.items = items ?? []
    }
}

Fewer lines. Type-safe error handling once you add try. No DispatchQueue.main.async ceremony. And — under load — a race condition the completion-handler version did not have. If refresh() is called twice in quick succession, both tasks run to completion. Whichever resolves second wins. The completion-handler version cancelled the previous request explicitly. The async version forgot to.

What task cancellation actually requires

Cancellation in Swift Concurrency is cooperative. Calling task.cancel() does not abort the work — it sets a flag the task must check. URLSession’s async API checks the flag at its suspension points and throws CancellationError. Your code does not check the flag at all unless you tell it to.

The fix for the view model needs two things: a handle to the previous task, and a check that you are still the active call by the time the data comes back.

// Production pattern: cancel the previous task before starting a new one
@MainActor
final class FeedViewModel: ObservableObject {
    private let service: FeedService
    @Published var items: [FeedItem] = []
    private var refreshTask: Task<Void, Never>?

    func refresh() {
        refreshTask?.cancel()
        refreshTask = Task {
            do {
                let items = try await service.loadFeed()
                try Task.checkCancellation()
                self.items = items
            } catch is CancellationError {
                // Expected when the user triggers a new refresh.
            } catch {
                // Surface the error through your usual path.
            }
        }
    }
}

Task.checkCancellation() is the pattern I wish someone had handed me on day one. After every await that touches state, you ask: is this work still relevant? If the answer is no, throw and bail out. URLSession does this for you on the network leg. Anything you do after the response — parsing, transforming, publishing — needs your own checkpoint. The single line try Task.checkCancellation() is the difference between a view model that respects user intent and one that races itself.

In production

The app’s feed is rebuilt on every data change, background sync, or pull-to-refresh. Three weeks after the async migration we started seeing intermittent reports of items “flickering” on screen — the right data, then briefly the wrong data, then the right data again. Crash-free sessions stayed at 100%. There was nothing to symbolicate. The bug was logical, not crashing.

The cause was actor reentrancy.

// Reentrancy: looks atomic, isn't
actor FeedCache {
    private var cachedItems: [FeedItem] = []

    func itemsRefreshing(from service: FeedService) async throws -> [FeedItem] {
        let fetched = try await service.loadFeed()
        cachedItems = fetched
        return fetched
    }
}

Actor methods are not transactions. The await inside itemsRefreshing is a suspension point — the actor releases its lock during the network call and accepts other calls in the meantime. If two screens call this method concurrently, both suspend on the network, both resume, both write to cachedItems. The last writer wins, but anything that read cachedItems between the two resumptions saw inconsistent state.

This is the bug that gets you in code review and then ships anyway, because everyone is looking at the actor declaration and trusting it. We hit this exact problem in the app’s feed cache — the fix was to serialise the fetch through a deduplicating task handle, not the actor’s implicit lock.

// Production fix: dedupe inflight work, do not rely on actor reentrancy
actor FeedCache {
    private var cachedItems: [FeedItem] = []
    private var inflight: Task<[FeedItem], Error>?

    func items(from service: FeedService) async throws -> [FeedItem] {
        if let inflight { return try await inflight.value }
        let task = Task { try await service.loadFeed() }
        inflight = task
        defer { inflight = nil }
        let fetched = try await task.value
        cachedItems = fetched
        return fetched
    }
}

One inflight task. All concurrent callers await the same value. The actor still protects the property writes, but the dedup is explicit instead of implicit. The flicker disappeared in the next release.

I covered the architecture behind this in the case study →.

The part the docs skip

Swift 6 strict concurrency turns runtime data races into compile errors. That is the headline. The part nobody warns you about is the second-order effect: every legacy type that crosses a concurrency boundary needs a verdict.

A typical migration session looks like this. You flip the build setting. The compiler produces 247 warnings. About 30 are real data races. About 80 are types that should be Sendable but are not marked. The remaining 130-ish are the ones you have to make a real decision about — types your team did not design with concurrency in mind, types from third-party libraries, types from your own SDK that you cannot easily annotate.

For those, the available tools are Sendable conformance when the type can genuinely be made safe to cross actors; @unchecked Sendable when you have audited it and the compiler cannot prove what you know; @MainActor isolation when the type only ever lives on the main actor and should stop pretending otherwise; and @preconcurrency import, which downgrades errors from a specific module to warnings — the controlled-burn option when a dependency has not migrated yet.

@unchecked Sendable is the one to be careful with. Every use of it is a promise to the compiler that you have done the analysis the compiler cannot do. In a year, when someone adds a mutable property to that type, the compiler will not warn them. We hit this in production where a value type acquired a lazy cache property and silently became unsafe to share. The fix was an actor wrapper around the cache, and a compile-time test that asserts Sendable conformance for the public surface.

// Compile-time assertion that a public type stays Sendable across releases
import XCTest
@testable import AppFeed

final class SendableContractTests: XCTestCase {
    func test_feedItem_isSendable() {
        func assertSendable<T: Sendable>(_ type: T.Type) {}
        assertSendable(FeedItem.self)
    }
}

The test body does nothing at runtime. The function it defines is empty. The value is that the test stops compiling if someone breaks the conformance — and a broken build is louder than a runtime race.

A worked example

Putting the patterns together. This is a stripped-down version of the request coordinator that runs in a production data layer. It handles cancellation, dedupes inflight work, and stays compatible with strict concurrency:

// Production-style coordinator: cancellation, dedup, Sendable-safe
actor RequestCoordinator<Key: Hashable & Sendable, Value: Sendable> {
    private var inflight: [Key: Task<Value, Error>] = [:]

    func value(
        for key: Key,
        produce: @Sendable @escaping () async throws -> Value
    ) async throws -> Value {
        if let existing = inflight[key] {
            return try await existing.value
        }
        let task = Task { try await produce() }
        inflight[key] = task
        defer { inflight[key] = nil }
        return try await task.value
    }

    func cancelAll() {
        inflight.values.forEach { $0.cancel() }
        inflight.removeAll()
    }
}

The view model holds one of these, keyed by feed identifier. A new screen calls value(for:). Tab switches call cancelAll(). Concurrent calls for the same key share a task. Concurrent calls for different keys run in parallel. No DispatchQueue. No locks. One actor.

What this does not do is help you with downstream work after the value arrives. If you transform the result and publish it to @MainActor state, that publish happens after the await — and you still need a cancellation check before writing. The coordinator is the bottom layer. The caller stays responsible for “is this work still wanted?”

Your turn

I want to know about the actor reentrancy bugs people are hitting in production right now. The compiler will not catch them. Code review usually does not. They surface as flicker, stale state, or “the cache forgot what I wrote.” If you have shipped one, fixed one, or found one in review, what was the shape of the bug — and what convinced you the fix was right?

If you think the dedup-by-task pattern above is over-engineered for your use case, say so. The right answer depends on how often the calls collide and how much it costs to do the work twice. The architecture decisions here are scale-dependent and I am not claiming they are universal.

One diagnostic exercise before you close the tab: open the file in your codebase with the most DispatchQueue.main.async calls. Count them. That number is roughly how much Swift Concurrency has left to do in your codebase.

Drop a comment below or email me — I read everything.

Keep going

If you want the architecture-level view of how these patterns sit inside a real codebase, I am writing a follow-up on iOS Architecture at Scale that covers Tuist module boundaries and how to enforce them in CI. For a different angle on building reliable user-facing systems on iOS, the AlarmKit Architect’s Guide walks through scheduling, persistence, and the failure modes you do not see until production.