iOS Engineer Hub

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 module

SE-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

ProposalStatusWhat it gives you
SE-0296 — async/awaitSwift 5.5The async / await syntax. Every other concurrency proposal builds on this.
SE-0297 — async interop with Objective-CSwift 5.5Auto-bridges (NS)Error completion-handler APIs to async throws. Why UIKit's image-loading APIs got automatic async equivalents.
SE-0300 — ContinuationsSwift 5.5withCheckedContinuation / withCheckedThrowingContinuation. The bridging primitive for legacy callbacks.
SE-0306 — ActorsSwift 5.5The actor keyword and isolation model. Reentrancy semantics defined here.
SE-0316 — Global actorsSwift 5.5@MainActor and custom global actors via @globalActor.
SE-0337 — Incremental migration to Concurrency CheckingSwift 5.6The -strict-concurrency=minimal/targeted/complete flags. The reason staged migration is possible.
SE-0414 — Region-based IsolationSwift 5.10 / 6.0Compiler proves transient ownership of values crossing actors. Eliminates ~80% of false-positive Sendable errors.
SE-0337 + SE-0431 — Isolated Synchronous DeinitSwift 6.0Allows 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

  1. WWDC24 — Migrate your app to Swift 6. Staged rollout walkthrough.
  2. WWDC22 — Eliminate data races using Swift Concurrency. Actor reentrancy at 19:00.
  3. WWDC21 — Meet async/await in Swift. The original concurrency intro.
  4. SE-0296 — async/await.
  5. SE-0306 — Actors. Defines isolation and reentrancy semantics.
  6. SE-0337 — Incremental migration to concurrency checking.
  7. SE-0414 — Region-based Isolation. Why strict mode is tractable in real apps.
  8. 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.