Swift 6 Concurrency for iOS Engineers (2026): Strict Mode in Production
In short
Swift 6 strict concurrency, default-on since the Swift 6.0 language mode (released October 2024), turns previously-warned data-race issues into compiler errors. The framework itself is stable; the failure mode in 2026 is migration — getting an existing UIKit codebase compiled against -strict-concurrency=complete without burying every value type under @unchecked Sendable. This page shows real actor / @MainActor / Sendable code, the common diagnostics ('Capture of self with non-sendable type' and 'Sending main-actor-isolated value of type X to nonisolated context'), the completion-handler-to-async migration pattern, and the SE-0296 / SE-0306 / SE-0337 proposals that grounded the model.
Key takeaways
- Swift 6 mode (Xcode 16+) makes -strict-concurrency=complete the default. SE-0337 (Region-based Isolation) added in Swift 5.9 + 6.0 reduces false positives on values that cross actor boundaries — without it, Swift 6 strict mode is unusable for real apps.
- @MainActor is the right default for all UIKit / SwiftUI types. UIView, UIViewController, and every SwiftUI View are now annotated @MainActor in the SDK — your code that touches them is implicitly main-isolated and that propagates through call chains.
- Custom actors solve state isolation, not throughput. An actor serialises access to its mutable state — perfect for an in-memory cache or a sync coordinator, wrong for a stateless image-decoding service that should run concurrently across cores.
- Sendable conformance is structural. A struct with all-Sendable stored properties is auto-Sendable; a class needs final + immutable storage or @unchecked Sendable + manual locking. The most common 2026 fix is making your model types final structs.
- Migration pattern: enable -strict-concurrency=targeted first, fix all warnings, then flip to complete. WWDC24 'Migrate your app to Swift 6' (developer.apple.com/videos/play/wwdc2024/10169) covers the staged rollout.
Actor isolation: a real coordinator and what serialisation actually buys you
An actor serialises access to its stored properties. Pick an actor when state needs to be coherent across concurrent callers — caches, in-memory database, sync coordinators. Don't pick one for compute that should fan out.
// Real cache used by an image pipeline
actor ImageCache {
private var entries: [URL: UIImage] = [:]
private var inflight: [URL: Task<UIImage, Error>] = [:]
private let maxBytes: Int
private var currentBytes = 0
init(maxBytes: Int = 50 * 1024 * 1024) { self.maxBytes = maxBytes }
func image(for url: URL) async throws -> UIImage {
if let hit = entries[url] { return hit }
// Coalesce concurrent requests for the same URL
if let task = inflight[url] { return try await task.value }
let task = Task<UIImage, Error> {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw URLError(.cannotDecodeContentData)
}
return image
}
inflight[url] = task
do {
let image = try await task.value
// Re-entered the actor here — state may have changed during await
inflight[url] = nil
entries[url] = image
currentBytes += image.byteSize
await evictIfNeeded()
return image
} catch {
inflight[url] = nil
throw error
}
}
private func evictIfNeeded() {
while currentBytes > maxBytes, let url = entries.keys.first {
if let img = entries.removeValue(forKey: url) {
currentBytes -= img.byteSize
}
}
}
}
private extension UIImage {
var byteSize: Int {
Int(size.width * size.height * scale * scale * 4)
}
}The non-obvious gotcha: actor reentrancy. Inside the do block above, the await task.value suspends and re-enters the actor. Between suspension and resumption, another caller could have added the same URL. The code handles this by checking entries[url] only at the start; the second-checker pattern is correct because the worst case is a transient duplicate decode, not a corrupted cache.
SE-0306 (Concurrency: github.com/swiftlang/swift-evolution/blob/main/proposals/0306-actors.md) defines actor semantics, including reentrancy. WWDC22 'Eliminate data races using Swift Concurrency' (developer.apple.com/videos/play/wwdc2022/110351) at 19:00 walks the reentrancy diagram.
@MainActor and the Capture-of-self diagnostic
The most common Swift 6 error in production migration is Capture of 'self' with non-sendable type 'X' in a `@Sendable` closure. The compiler emits this when a Task captures self and self isn't Sendable.
// FAILS in Swift 6 strict mode
@MainActor
final class FeedViewModel {
var items: [Post] = []
private let api: APIClient // not Sendable
func reload() {
Task {
// ❌ Capture of 'self' with non-sendable type 'FeedViewModel'
let posts = try await api.fetch()
self.items = posts
}
}
}
// FIX 1 — annotate self's class @MainActor (already done above)
// AND mark the Task as @MainActor explicitly so the compiler knows
// self stays main-isolated
@MainActor
final class FeedViewModel {
var items: [Post] = []
private let api: APIClient
func reload() {
Task { @MainActor in
let posts = try await api.fetch()
self.items = posts
}
}
}
// FIX 2 — for non-MainActor classes, conform to Sendable explicitly
final class APIClient: Sendable {
let baseURL: URL // immutable, Sendable type
init(baseURL: URL) { self.baseURL = baseURL }
}
// FIX 3 — if you genuinely need shared mutable state without an actor,
// @unchecked Sendable + manual lock. Last resort.
final class CounterCache: @unchecked Sendable {
private var count = 0
private let lock = NSLock()
func increment() {
lock.lock(); defer { lock.unlock() }
count += 1
}
}The thing senior engineers learn the hard way: Task { @MainActor in ... } is not the same as await MainActor.run { ... }. The first creates a new top-level task on the main actor; the second is a direct hop on the current task. Inside an already-MainActor method, both produce identical behaviour, but if you call from a non-isolated context, only MainActor.run guarantees ordering with the surrounding code.
Sendable: making your domain types cross actors
Sendable is the type-system gate for cross-actor data movement. Three patterns get you 95% of the way there:
// 1. Value types with Sendable members are auto-Sendable
struct Post: Sendable {
let id: UUID
let title: String
let createdAt: Date
let tags: [String]
}
// 2. Final classes with immutable Sendable storage
final class Author: Sendable {
let id: UUID
let name: String
let joinedAt: Date
init(id: UUID, name: String, joinedAt: Date) {
self.id = id; self.name = name; self.joinedAt = joinedAt
}
}
// 3. Actor types are implicitly Sendable
actor RateLimiter { /* ... */ }
// THE DIAGNOSTIC YOU'LL SEE
// 'Sending main-actor-isolated value of type X to nonisolated context'
// Means: you're handing a non-Sendable value across an isolation boundary.
//
// The fix in Swift 6 with SE-0414 'Region-based Isolation':
// the compiler proves the value is uniquely owned at the call site.
// You write the same code; if it's safe, it now compiles.
// preconcurrency import for legacy modules that haven't adopted Sendable
@preconcurrency import LegacyKit // suppresses Sendable diagnostics from this moduleSE-0414 (Region-based Isolation, github.com/swiftlang/swift-evolution/blob/main/proposals/0414-region-based-isolation.md) is what made strict mode tractable in real codebases — without it, every transient value passing between actors needed Sendable conformance, which is impossible for class types from third-party frameworks. WWDC24 'Consume noncopyable types in Swift' covers the related ownership concepts.
Migrating completion handlers to async/await
Most production iOS codebases in 2026 still have completion-handler APIs in legacy modules. The bridging pattern uses withCheckedContinuation (or withCheckedThrowingContinuation for throwing variants):
// Legacy — completion-handler API
func fetchUser(
id: String,
completion: @escaping (Result<User, Error>) -> Void
) { /* ... */ }
// Bridge — async wrapper. Resume MUST be called exactly once.
func fetchUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
fetchUser(id: id) { result in
switch result {
case .success(let user): continuation.resume(returning: user)
case .failure(let error): continuation.resume(throwing: error)
}
}
}
}
// SE-0297 generated equivalent — the compiler can synthesise this when the
// completion handler is the last parameter and matches the Result-or-throws
// shape. Add @available + ObjC selector for backward compat:
// func fetchUser(id: String) async throws -> User
// Cancellation — propagate via Task.checkCancellation()
func fetchAll(ids: [String]) async throws -> [User] {
try await withThrowingTaskGroup(of: User.self) { group in
for id in ids {
group.addTask {
try Task.checkCancellation()
return try await fetchUser(id: id)
}
}
var users: [User] = []
for try await user in group { users.append(user) }
return users
}
}The trap: withCheckedContinuation requires the continuation be resumed exactly once. Resuming twice traps in debug, leaks in release; never resuming hangs the calling task forever. SE-0300 (Continuations, github.com/swiftlang/swift-evolution/blob/main/proposals/0300-continuation.md) defines the contract.
The SE proposals that ground the model
| Proposal | Status | What it gives you |
|---|---|---|
| SE-0296 — async/await | Swift 5.5 | The async / await syntax. Every other concurrency proposal builds on this. |
| SE-0297 — async interop with Objective-C | Swift 5.5 | Auto-bridges (NS)Error completion-handler APIs to async throws. Why UIKit's image-loading APIs got automatic async equivalents. |
| SE-0300 — Continuations | Swift 5.5 | withCheckedContinuation / withCheckedThrowingContinuation. The bridging primitive for legacy callbacks. |
| SE-0306 — Actors | Swift 5.5 | The actor keyword and isolation model. Reentrancy semantics defined here. |
| SE-0316 — Global actors | Swift 5.5 | @MainActor and custom global actors via @globalActor. |
| SE-0337 — Incremental migration to Concurrency Checking | Swift 5.6 | The -strict-concurrency=minimal/targeted/complete flags. The reason staged migration is possible. |
| SE-0414 — Region-based Isolation | Swift 5.10 / 6.0 | Compiler proves transient ownership of values crossing actors. Eliminates ~80% of false-positive Sendable errors. |
| SE-0337 + SE-0431 — Isolated Synchronous Deinit | Swift 6.0 | Allows deinit of an actor type to safely access actor-isolated state. |
The full proposal list lives at swift.org/swift-evolution. WWDC24 'Migrate your app to Swift 6' (developer.apple.com/videos/play/wwdc2024/10169) at 7:30 walks through enabling strict mode incrementally per target.
Frequently asked questions
- What's the difference between Task { @MainActor in ... } and await MainActor.run { ... }?
- Task { @MainActor in ... } creates a new top-level Task isolated to MainActor. await MainActor.run { ... } is a synchronous hop on the current task. Three operational differences: (1) Task creates a new cancellation scope; MainActor.run inherits the parent's; (2) Task ordering relative to surrounding code is undefined; MainActor.run executes inline; (3) Task can be passed around as a value; MainActor.run cannot. Use MainActor.run for tightly-scoped UI updates from a background context. Use Task { @MainActor in ... } for fire-and-forget UI work.
- When do I need @unchecked Sendable?
- When you have shared mutable state managed by something the type system can't see — for example, a class that wraps a C library or that uses NSLock for synchronisation. The contract: you swear the type is thread-safe, the compiler trusts you. Document the synchronisation invariant in a comment. Production codebases use @unchecked Sendable for objects like custom queue wrappers, hand-rolled atomic primitives, and Core Foundation interop types.
- How do I handle cancellation propagation in long async work?
- Three primitives: (1) Task.checkCancellation() throws CancellationError if the task was cancelled — call at safe stopping points; (2) Task.isCancelled returns Bool without throwing — for cleanup paths; (3) withTaskCancellationHandler { work } onCancel: { cleanup } for resources that need explicit teardown. URLSession's data(from:) automatically respects cancellation; custom Task code that loops or waits must check explicitly.
- What is nonisolated(unsafe) and when do I use it?
- nonisolated(unsafe) is the escape hatch on actor properties — it disables both isolation and Sendable checking on a stored property. Use it for two cases: (1) interop with C globals that are written exactly once at startup; (2) properties protected by their own internal locking. SE-0412 added it specifically because @unchecked Sendable applied to whole types where you only needed it on one property. Document the safety argument in a comment.
- How do I test code that uses async/await?
- XCTest fully supports async test methods (XCTestCase requires Xcode 13+). Use Task { ... } and await inside. For testing actor isolation, expose an internal actor-isolated test method or use confirmation(...) (Swift Testing framework, Xcode 16+). Avoid sleeping for time-based assertions — inject a Clock (SE-0329) so tests can use ContinuousClock vs a fake clock.
- Should I prefer async let or TaskGroup?
- async let when you have a fixed, statically-known set of parallel operations and you want their results bound to named variables. TaskGroup when the number of subtasks is data-driven (loop over a collection) or when you need to cancel siblings on first failure (group.cancelAll()). async let is sugar over single-task creation; TaskGroup is the general primitive.
- What's @preconcurrency for?
- @preconcurrency on an import statement suppresses Sendable / actor-isolation diagnostics from that module — useful for third-party SDKs that haven't adopted Swift 6 yet. @preconcurrency on a function declaration marks it as 'available pre-concurrency' so callers in modules without strict checking can still use it. Both are scaffolding for the migration period; remove them once dependencies are updated.
Sources
- WWDC24 — Migrate your app to Swift 6. Staged rollout walkthrough.
- WWDC22 — Eliminate data races using Swift Concurrency. Actor reentrancy at 19:00.
- WWDC21 — Meet async/await in Swift. The original concurrency intro.
- SE-0296 — async/await.
- SE-0306 — Actors. Defines isolation and reentrancy semantics.
- SE-0337 — Incremental migration to concurrency checking.
- SE-0414 — Region-based Isolation. Why strict mode is tractable in real apps.
- Swift Evolution dashboard — full proposal index.
About the author. Blake Crosley founded ResumeGeni and writes about product design, hiring technology, and ATS optimization. More writing at blakecrosley.com.