Skip to main content

Swift 6 Migration: Fixing 'Strict Concurrency' & Sendable Errors

 The transition to Swift 6 is the most significant shift in the language's history since the introduction of SwiftUI. If you have recently set SWIFT_STRICT_CONCURRENCY to complete in your build settings, you likely woke up to a "sea of red"—hundreds, perhaps thousands, of compile-time errors flagging data races and actor isolation violations.

This is not a linter becoming overly aggressive; it is a fundamental change in how Swift guarantees memory safety. The compiler is no longer assuming your code is safe—it now demands proof.

This guide analyzes the root causes of "Sendable" and Actor isolation errors and provides architectural patterns to resolve them without compromising performance or resorting to unsafe workarounds.

The Root Cause: From Implicit to Explicit Safety

In Swift 5, concurrency was often handled via documentation or convention. You might comment // ensure this is called on the main thread, but the compiler had no way to enforce it.

Swift 6 introduces Data Isolation as a first-class language feature. The compiler now tracks the "domain" (or isolation context) of every variable and function.

The Sendable Protocol

The crux of most strict concurrency errors is the Sendable protocol. A type is Sendable if its values can safely be passed across concurrency domains (e.g., from a background Task to the MainActor).

  • Value types (Structs/Enums): Implicitly Sendable if their properties are Sendable.
  • Reference types (Classes): Not Sendable by default because two threads can hold a reference to the same mutable object, leading to race conditions.
  • Closures: Not Sendable by default unless annotated, as they might capture mutable state from their creation context.

When you see Type 'MyClass' does not conform to protocol 'Sendable', the compiler is preventing a potential race condition before it happens.


Scenario 1: Fixing "Non-Sendable" Class Errors

The most common error occurs when passing a standard reference type into a Task or between actors.

The Problem

You have a model class holding mutable state.

class UserProfile {
    var username: String
    var lastActive: Date

    init(username: String) {
        self.username = username
        self.lastActive = Date()
    }
}

func updateProfile(_ profile: UserProfile) async {
    // ERROR: Passing argument of non-sendable type 'UserProfile' 
    // outside of its actor-isolated context
    await API.send(profile)
}

Solution A: Convert to Value Semantics (Preferred)

If the identity of the object doesn't matter (i.e., you don't need to share the exact instance pointer), convert it to a struct. Structs with standard properties are implicitly Sendable.

struct UserProfile: Sendable {
    var username: String
    var lastActive: Date
}
// This now compiles without warnings.

Solution B: Final Classes with Immutable State

If you must use a class (perhaps for Objective-C interoperability or specific library requirements), you can make it Sendable if it is immutable.

final class UserProfile: Sendable {
    let username: String
    let lastActive: Date // Must be 'let'

    init(username: String) {
        self.username = username
        self.lastActive = Date()
    }
}

Note: The class must be final to prevent subclassing, which could introduce mutable state.

Solution C: Actor Isolation

If you need shared mutable state, the Swift 6 answer is an actor. Actors serialize access to their state.

actor UserProfileManager {
    private var activeProfiles: [String: UserProfile] = [:]

    func update(_ profile: UserProfile) {
        activeProfiles[profile.username] = profile
    }
    
    func getProfile(id: String) -> UserProfile? {
        return activeProfiles[id]
    }
}

// Usage
let manager = UserProfileManager()
// Must use 'await' to enter the actor's isolation domain
await manager.update(myProfile)

Scenario 2: Handling Legacy Singletons

Many codebases rely on singletons that are accessed from arbitrary threads. In Swift 6, global variables are restricted because they are unsafe shared state.

The Problem

class NetworkLogger {
    static let shared = NetworkLogger()
    var logs: [String] = [] // Mutable state accessed globally
    
    func log(_ message: String) { 
        logs.append(message) 
    }
}
// ERROR: Static property 'shared' is not concurrency-safe because it is not either
// conforming to 'Sendable' or isolated to a global actor.

The Fix: Global Actor Isolation

Usually, singletons interact heavily with the UI or coordinate application state. The easiest fix is often isolating the singleton to the @MainActor. This ensures all access happens on the main thread, eliminating data races.

@MainActor
class NetworkLogger {
    static let shared = NetworkLogger()
    var logs: [String] = []
    
    func log(_ message: String) {
        logs.append(message)
    }
}

// Usage in background code:
func performBackgroundWork() async {
    let result = calculatePrimes()
    
    // Must await the call to jump to the MainActor
    await NetworkLogger.shared.log("Calculation complete: \(result)")
}

If the singleton must perform heavy work and should not block the main thread, convert it to an actor.


Scenario 3: Escaping Closures and Delegates

Delegates and callbacks are frequent sources of "Capture of non-sendable type" errors.

The Problem

class DataFetcher {
    // ERROR: parameter 'completion' is implicitly non-sendable
    func fetch(completion: @escaping (Result<String, Error>) -> Void) {
        Task {
            let data = await expensiveNetworkCall()
            completion(.success(data))
        }
    }
}

When Task executes, it might run on a background thread. If the completion closure captures mutable state from the caller (which might be on the Main Actor), executing it from the background is a race condition.

The Fix: @Sendable Annotation

You must explicitly tell the compiler that the closure is safe to pass between concurrency domains.

class DataFetcher {
    // 1. Add @Sendable to the closure definition
    func fetch(completion: @escaping @Sendable (Result<String, Error>) -> Void) {
        Task {
            let data = await expensiveNetworkCall()
            completion(.success(data))
        }
    }
}

However, adding @Sendable imposes restrictions on the closure: it cannot capture mutable local variables, and everything it captures must be Sendable.

Managing "Context" in Closures

If you are calling this from a View Controller, you might get an error because self (the UIViewController) is not Sendable.

// Inside a UIViewController
func refreshData() {
    fetcher.fetch { [weak self] result in
        // ERROR: Capture of 'self' with non-sendable type 'MyViewController'
        self?.handle(result)
    }
}

To fix this, we rely on the fact that UIViewController is bound to the @MainActor. We need to ensure the closure is executed in the correct isolation context.

func refreshData() {
    // Ensure the fetcher understands where to call back, 
    // OR allow the closure to jump back to the actor manually.
    
    fetcher.fetch { [weak self] result in
        // Valid approach: Recapture MainActor isolation
        await MainActor.run {
            self?.handle(result)
        }
    }
}

Deep Dive: @unchecked Sendable (The Nuclear Option)

Sometimes, you have a class that is thread-safe (perhaps using an internal DispatchQueue or NSLock), but the compiler cannot verify it because it can't see inside your implementation or C++ dependencies.

In this case, you use @unchecked Sendable.

import Foundation

// The compiler cannot verify the lock makes this safe, so we promise it is.
final class ThreadSafeCache: @unchecked Sendable {
    private var storage: [String: Any] = [:]
    private let lock = NSLock()

    func set(_ value: Any, forKey key: String) {
        lock.lock()
        defer { lock.unlock() }
        storage[key] = value
    }
}

Warning: Use this sparingly. You are disabling compiler safety checks. If you forget to lock even one property access, you introduce a data race that Swift 6 would have otherwise caught.


Handling 3rd Party Dependencies: @preconcurrency

A major pain point is importing libraries that haven't updated to Swift 6 strict concurrency yet. You will see errors claiming types from that library aren't Sendable.

You cannot edit their code. Instead, use @preconcurrency import.

@preconcurrency import OldObjectiveCLibrary

struct MyWrapper {
    // The compiler will downgrade errors to warnings or suppress them 
    // for types coming from this specific import.
    let legacyObject: OldLibraryObject 
}

This tells Swift: "I know this library doesn't strictly conform to Sendable, but assume it does for now so I can build." This allows you to migrate your code without waiting for the entire ecosystem to catch up.

Conclusion

Swift 6 Strict Concurrency is not about sprinkling await keywords randomly until code compiles. It requires a mental shift regarding memory ownership.

  1. Prefer Value Types: Structs are your best defense against concurrency complexity.
  2. Isolate State: Use actor for shared mutable state or @MainActor for UI state.
  3. Annotate Boundaries: Use @Sendable for closures crossing actor boundaries.
  4. Retrofit carefully: Use @unchecked Sendable only for internally synchronized classes and @preconcurrency import for legacy dependencies.

By addressing the root architecture rather than suppressing warnings, you ensure your app is prepared for the future of the Apple ecosystem.