← All writing

Surviving Swift 6 Strict Concurrency: A Practical Guide for Senior iOS Developers

Turning on Swift 6 strict concurrency usually generates hundreds of compiler errors overnight. Here is a battle-tested mental model for actors, Sendable types, task boundaries, and bridging legacy APIs without slapping @unchecked Sendable everywhere.


When you enable Swift 6 mode in Xcode, the compiler suddenly feels like an adversary. Code that compiled cleanly and ran in production without apparent bugs for five years is suddenly flagged with red diagnostics across dozens of files:

Capture of 'self' with non-sendable type 'ProfileService' in a `@Sendable` closure
Passing argument of non-sendable type '(any Error)? -> Void' into actor-isolated context
Main actor-isolated property 'items' can not be mutated from a non-isolated context

The immediate temptation for many teams under deadline pressure is to reach for escape hatches: sprinkle @unchecked Sendable across every model class, add @preconcurrency import to third-party frameworks, and slap @MainActor onto random utility functions until Xcode stops shouting.

That approach is dangerous. It trades compile-time verification for runtime data races, defeating the entire purpose of Swift’s concurrency model.

Swift 6 is not being pedantic for the sake of academic purity. It is eliminating the single most elusive class of mobile crashes: concurrent access to shared mutable memory.

Once you understand three fundamental rules of the Swift 6 concurrency model, the compiler errors stop feeling like arbitrary roadblocks and start looking like clear architectural design cues.

The core mental model: three questions

Whenever the Swift 6 compiler flags a concurrency diagnostic, it is asking three specific questions:

  1. Where does this state live? (Is it isolated to @MainActor, isolated to a custom actor, or non-isolated?)
  2. Can this value safely cross a thread boundary? (Is it Sendable?)
  3. Who is executing this closure? (Does the closure run synchronously on the current thread, or asynchronously across a Task?)

If you can answer those three questions for every type and function in your feature, the fix becomes obvious.

Rule 1: Prefer value types over classes for domain state

The single highest-leverage change you can make when preparing an iOS codebase for Swift 6 is converting mutable model classes into immutable structs:

// ❌ Problematic in Swift 6: Reference type shared across tasks
final class OrderModel {
    var id: String
    var items: [String]
    var isProcessed: Bool = false

    init(id: String, items: [String]) {
        self.id = id
        self.items = items
    }
}

Because OrderModel is a mutable reference type, passing an instance from a background networking task to a SwiftUI view model creates a data race: both threads hold a pointer to the exact same heap memory. The compiler will reject this unless OrderModel conforms to Sendable — which it cannot safely do while its properties are mutable var.

The fix is almost always to model state as a value type:

// ✅ Safe in Swift 6: Value semantics mean automatic Sendable conformance
struct Order: Sendable, Identifiable, Equatable {
    let id: String
    let items: [String]
    let isProcessed: Bool
}

When a struct contains only Sendable properties (like String, Int, Array, or other value types), Swift synthesizes Sendable automatically. When you pass an Order into a background Task, Swift passes an independent copy. There is no shared memory, no lock contention, and zero compiler resistance.

Rule 2: Put shared mutable state behind an Actor

Value types solve state transmission, but you still need places where mutable state lives over time — like an in-memory token cache, an image cache, or an active download manager.

Instead of synchronizing with manual GCD queues (DispatchQueue(label: ...)) or os_unfair_lock, encapsulate that state inside an actor:

// ✅ An actor guarantees mutually exclusive access to its internal state
actor TokenStorage {
    private var accessToken: String?
    private var expirationDate: Date?

    func updateToken(_ token: String, expiresAt: Date) {
        self.accessToken = token
        self.expirationDate = expiresAt
    }

    func validToken() -> String? {
        guard let token = accessToken, let expirationDate, expirationDate > Date() else {
            return nil
        }
        return token
    }

    func clear() {
        self.accessToken = nil
        self.expirationDate = nil
    }
}

Because TokenStorage is an actor, callers cannot mutate accessToken directly. Calling tokenStorage.updateToken(...) requires await, ensuring that even if five concurrent network requests try to refresh a token simultaneously, access is serialized safely without data races or manual lock management.

Rule 3: Bridging legacy completion handlers with continuations

Most existing iOS codebases interact with legacy SDKs or Objective-C frameworks that rely on closure completion handlers:

// Legacy API
func fetchUserProfile(userId: String, completion: @escaping (Result<UserProfile, Error>) -> Void)

If you call this from an async context in Swift 6, you will often trigger errors regarding non-Sendable closure captures or crossing actor boundaries.

The clean bridge is wrapping completion handlers using Swift’s checked continuations:

extension ProfileService {
    func userProfile(for userId: String) async throws -> UserProfile {
        try await withCheckedThrowingContinuation { continuation in
            self.fetchUserProfile(userId: userId) { result in
                switch result {
                case .success(let profile):
                    continuation.resume(returning: profile)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

Continuations turn asynchronous callback hell into standard, linear async/await syntax. withCheckedThrowingContinuation also verifies at runtime that the continuation is resumed exactly once — if a third-party SDK calls the completion twice or forgets to call it at all, Xcode will trap immediately with an explicit error.

Aligning SwiftUI ViewModels with @MainActor

In modern SwiftUI (especially iOS 17+ with the @Observable macro), view models belong on the main actor:

import Observation
import SwiftUI

@Observable
@MainActor
final class OrderDetailViewModel {
    var order: Order?
    var isLoading: Bool = false
    var errorMessage: String?

    private let orderService: OrderFetching // Sendable protocol

    init(orderService: OrderFetching) {
        self.orderService = orderService
    }

    func load(orderId: String) async {
        isLoading = true
        errorMessage = nil

        do {
            // Network fetch executes off the main thread
            self.order = try await orderService.fetchOrder(id: orderId)
        } catch {
            self.errorMessage = error.localizedDescription
        }

        isLoading = false
    }
}

Notice what happens here:

  • The class is annotated with @MainActor. All property access (isLoading, order, errorMessage) is guaranteed to happen on the main thread, meaning SwiftUI can read and observe them without UI stutter or background publishing warnings.
  • The orderService.fetchOrder method is non-isolated and runs on cooperative background threads. When it returns, execution hops back onto @MainActor automatically before updating self.order.
  • You never need to write DispatchQueue.main.async again.

When (and when NOT) to use @unchecked Sendable

There is one legitimate use case for @unchecked Sendable: when a class manages its own internal synchronization using thread-safe primitives that the Swift compiler cannot verify automatically (such as an internal C library or atomic locks).

// Legitimate use: internal state protected by a fair lock
final class ConcurrentCounter: @unchecked Sendable {
    private var lock = os_unfair_lock_s()
    private var count = 0

    func increment() {
        os_unfair_lock_lock(&lock)
        count += 1
        os_unfair_lock_unlock(&lock)
    }

    func currentCount() -> Int {
        os_unfair_lock_lock(&lock)
        defer { os_unfair_lock_unlock(&lock) }
        return count
    }
}

If you are using @unchecked Sendable on a regular model class just because you have var properties and didn’t feel like refactoring them into a struct or actor, you have introduced an untracked data race into your application.

The migration checklist for existing apps

If you have an existing iOS app targeting Swift 5 and want to migrate cleanly to Swift 6:

  1. Enable complete concurrency warnings first: Set SWIFT_STRICT_CONCURRENCY = complete in Build Settings under Swift 5 mode. Fix the warnings gradually while your builds still pass.
  2. Audit your models: Convert classes that represent data transfers or API responses into immutable struct types.
  3. Decorate UI view models with @MainActor: Make actor boundaries explicit.
  4. Wrap completion handlers in async/await: Replace completion blocks with withCheckedThrowingContinuation.
  5. Only then flip the language version to Swift 6: When the warnings drop to zero, enable Swift 6 mode.

Swift 6 strict concurrency feels challenging at first because it forces decisions you were previously able to postpone. But once your codebase complies with its rules, random race conditions, sporadic UI update glitches, and intermittent multithreaded crash reports disappear completely.