2026-05-25
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 language mode turns strict concurrency checking from warnings into errors, and that is the part of adopting 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: the patterns that absorb the gotchas, the Swift 6 errors you are going to see next sprint, and how to turn the checking on without stopping releases.
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] = []
init(service: FeedService) { self.service = service }
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] = []
init(service: FeedService) { self.service = service }
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 does check it, and cancels the request — but it throws URLError(.cancelled), not CancellationError. Your own 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>?
init(service: FeedService) { self.service = service }
func refresh() async {
refreshTask?.cancel()
let task = Task {
do {
let items = try await service.loadFeed()
try Task.checkCancellation()
self.items = items
} catch {
// A newer refresh cancelled this one. checkCancellation()
// throws CancellationError; URLSession throws
// URLError(.cancelled). Either way, stay quiet.
guard !Task.isCancelled else { return }
// Surface the error through your usual path.
}
}
refreshTask = task
await task.value
}
}
Task.checkCancellation() is the line that matters. 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.
The catch checks Task.isCancelled rather than matching CancellationError, because the two cancellation paths throw different errors. Match only CancellationError and every quick second refresh that lands mid-request shows the user an error.
refresh() stays async and awaits its own task. SwiftUI’s .refreshable keeps the spinner up until its closure returns, so a refresh() that only starts a task drops the spinner before the data lands.
Bridging what you have not migrated
The easy half of a migration is mostly wrappers: async functions over completion-handler APIs you are not ready to rewrite. Every one of them is a continuation.
// Async wrapper over a callback API that has not been migrated yet
extension LegacyFeedClient {
func loadFeed() async throws -> [FeedItem] {
try await withCheckedThrowingContinuation { continuation in
fetchFeed { result in
continuation.resume(with: result)
}
}
}
}
A continuation must be resumed exactly once, and the legacy API decides whether that happens. If the callback fires twice — once from a cache and again from the network is the classic shape — the checked continuation stops the app:
Fatal error: SWIFT TASK CONTINUATION MISUSE: loadFeed() tried to resume its continuation more than once
If a code path returns without calling back, nothing crashes. The runtime logs that loadFeed() “leaked its continuation without resuming it”, and the caller stays suspended forever — in the UI, a spinner that never stops. Completion handlers tolerated both bugs; a continuation turns them into a crash or a hang. Audit every callback path before you wrap it, and keep the checked variant in release builds: withUnsafeThrowingContinuation removes the checks, not the bugs.
The wrapper also ignores cancellation. Cancelling the task does not stop fetchFeed, and the caller keeps waiting until the callback fires anyway. If the legacy client can cancel its request, bridge that with withTaskCancellationHandler; if it cannot, a cancelled refresh still costs a full request.
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 did not move. 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. An actor runs one job at a time, but while this method is suspended on the network it is free to run other calls — including a second call to the same method. If two screens call it concurrently, both suspend on the network, both resume, both write to cachedItems. The last writer wins, and the last writer is whichever response arrived last, not whichever request was newest.
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 share one in-flight fetch through a task handle, instead of trusting the actor to make the method atomic.
// 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.
Joining is not free, though. A caller that arrives after a data change and joins a fetch that started before it gets pre-change data back. Deduplication is only correct when a request already in flight is fresh enough to answer the new caller. When it is not, do what the view model does instead: cancel the old request and start a new one.
Turning it on without stopping releases
Do not flip the whole app to Swift 6 mode in one pull request. The compiler gives you a ramp, and every step of it ships:
- Stay in Swift 5 mode and set Strict Concurrency Checking (
SWIFT_STRICT_CONCURRENCY) totargeted, thencomplete. You get the Swift 6 analysis as warnings, and the build stays green. - Burn the warnings down one module at a time, leaf modules first — models and utilities before the UI that depends on them.
- When a module reaches zero, move it to Swift 6 mode. The same diagnostics become errors, and the module cannot regress.
The warnings tell you exactly what is coming. Take a file most codebases have some version of:
final class ImageCache {
static var shared = ImageCache()
var images: [URL: Data] = [:]
}
final class SessionStore {
var token: String?
}
@MainActor
final class ProfileViewModel {
let store = SessionStore()
func load() {
Task.detached {
let token = self.store.token
_ = token
}
}
}
With complete checking in Swift 5 mode, it compiles with three warnings:
warning: static property 'shared' is not concurrency-safe because it is nonisolated global shared mutable state; this is an error in the Swift 6 language mode
warning: non-Sendable type 'SessionStore' of property 'store' cannot exit main actor-isolated context; this is an error in the Swift 6 language mode
warning: main actor-isolated property 'store' cannot be accessed from outside of the actor; this is an error in the Swift 6 language mode
In Swift 6 mode, the same three lines are errors. In a Swift package, the language mode is set per target, so the ramp is explicit:
// Package.swift
.target(name: "Models", swiftSettings: [.swiftLanguageMode(.v6)]),
.target(name: "Feature", dependencies: ["Models"], swiftSettings: [
.swiftLanguageMode(.v5),
.enableUpcomingFeature("StrictConcurrency"),
]),
Swift 6.2 adds one more lever: Default Actor Isolation (SWIFT_DEFAULT_ACTOR_ISOLATION, or .defaultIsolation(MainActor.self) in a package). Set to MainActor, it treats unannotated code in the module as main-actor code, which is what most UI code already assumes. In the file above, three errors become one: the cache and the store are main-actor now, and only the Task.detached that deliberately leaves the main actor still complains. It suits app and UI modules. Leave networking and persistence modules nonisolated, where a main-actor default would put their work on the main thread.
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 — a lazy var cache on a class, say — the compiler will not warn them, and no test will either, because the conformance is still there. Keep @unchecked rare, write down next to each one what makes it safe, and when a type needs shared mutable state, move that state into an actor rather than widening the promise.
Checked conformances fail more quietly. Swift infers Sendable for internal structs and enums whose stored properties are all Sendable. Add one property that is not, and the conformance disappears without an error at the type itself — the errors surface wherever the type is sent across a boundary, possibly far from the change. A compile-time test pins it where you can see it:
// Compile-time assertion that an internal 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 {
// After cancelAll(), a newer call may own this key.
if inflight[key] == task { 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.
The guarded defer is there because of cancelAll(). A cancelled call can finish unwinding after a newer call has registered its own task under the same key; an unconditional inflight[key] = nil would delete the newer entry, and the next caller would start a duplicate request. Reentrancy again — this time in the fix.
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.
Tell me through the contact page — 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.