Table of Contents

Swift 6 migration is a burden because thread-safety is unrelated to the feature you are implementing. All you can do is knowing how to deal with it, including when to ignore it. Also, a warning: this stuff is boring. If you want the gist of it:

Don't expose non-Sendables to more than one isolation domain.

deinit

@MainActor
class Bar {
    private var observer: NSObjectProtocol?
    deinit {
        // đŸ’„ Cannot access property 'observer' with a non-sendable 
        // type '(any NSObjectProtocol)?' from nonisolated deinit
        observer = nil
    }
}

Class deinitializers are solved in Swift 6.1 SE-0371 Isolated synchronous deinit. The following is implemented in Swift 6.1, which as of Xcode 16.2 is not available.

@MainActor
class MyViewController: UIViewController {
    private var observer: NSObjectProtocol?

    init() {
        super.init(nibName: nil, bundle: nil)
        observer = NotificationCenter.default.addObserver(forName: ...
    }

    isolated deinit { // <-- add isolated here
        if let observer {
            NotificationCenter.default.removeObserver(observer)
        }
    }
}

In Swift 6.1 the isolated keyword for deinit will inherit the actor isolation of the containing class (in this case @MainActor), ensuring the cleanup happens on the main thread.

But for the time being, here is a workaround:

// non isolated
final class TaskHolder {
    var task: Task<Void, Never>?

    deinit {
        task?.cancel()
    }
}

// isolated
@MainActor
final class Foo {
    // var task: Task<Void, Never>?
    let taskHolder = TaskHolder()
    ...
}

The TaskHolder’s deinit is implicitly isolated to @MainActor since it’s created in a main actor context. When LoadingViewModel is deallocated, deinit will be called on the main thread, safely cancelling the task.

Asynchronous Function Call Errors

Error: Call to asynchronous function 'foo()' in a synchronous function
Error: Missing 'await' in call to asynchronous function
Error: Expression is 'async' but is not marked with 'await'

These errors arise when you try to call an async function without:

  • Marking the calling function as asynchronous, or
  • Using the await keyword to handle the asynchronous call properly.
  • In Swift’s strict concurrency model, every asynchronous operation must be explicitly awaited. This enforces clarity around when and where suspension points occur.

How to Fix It:

  • Update your function signature to be async if it needs to call async functions.
  • Use await when calling async functions, ensuring that you are handling potential suspension correctly.

Sendability and Data Race Protections

Error: Non-sendable type 'T' used in concurrent actor-isolated context

Swift’s concurrency model enforces that types crossing actor boundaries (or used concurrently) must be safe to transfer between threads. A type that isn’t marked as Sendable might introduce data races. This error indicates that you’re using a type in a concurrent context where the compiler cannot guarantee its thread safety.

How to Fix It:

  • If the type is indeed safe for concurrent use, you can conform it to Sendable.
  • Otherwise, consider refactoring to an immutable type or introducing internal synchronization and making it @unchecked Sendable.

Actor Isolated Protocols

I wrote about this in a previous article.

@MainActor Isolation Violations

Same as Holy Borla’s Calling an actor-isolated method from a synchronous nonisolated context

class Foo {
    // đŸ’„ Main actor-isolated default value in a nonisolated context
    var window = UIWindow()
}

In Swift 6 view related objects are annotated with @MainActor so you can’t use them outside that isolation context. What follows are variations of this.

Error: call to main actor-isolated initializer 'init()' in a synchronous nonisolated context​
Error: Main actor-isolated property 'foo' cannot be referenced from a nonisolated context
Error: Cannot form key path to main actor-isolated property 'foo'

Again, calling an initializer (or method) marked with @MainActor from code that isn’t on the main actor. The solution is to wrap the call in an await MainActor.run {} or Task { @MainActor in }. A temporary workaround could be to use MainActor.assumeIsolated if you are sure such code will only be called from the main actor.

Error: Main actor-isolated property 'foo' cannot be referenced from a Sendable closure.

This time we are calling a main isolated property from a Sendable closure. Because it is Sendable, you are going to send that property away from its isolation context.

This error often appears in SwiftUI or other contexts where a closure is marked @Sendable (implicitly by the system) and you capture a @MainActor state inside it. For instance, using a SwiftUI .task { ... } modifier (which runs concurrently) and capturing a @State (main actor) property inside can produce this error.

Several solutions:

  • Mark the closure as running on the main actor, or ensure any UI state is accessed on the main thread. In SwiftUI, you can call await MainActor.run within the task to update UI state​.

  • Or move the state into a model that is made thread-safe or Sendable. In one case, a developer created a separate view model and declared it @unchecked Sendable to hold the image data, then used MainActor.run for UI updates​

  • Or Design your concurrency so that UI-bound data is only touched on the main actor (for example, by marking the entire view model @MainActor or updating SwiftUI @State within DispatchQueue.main.async or an actor). In short, avoid capturing main-actor state in detached concurrent closures; perform UI updates on the main actor.

Sendable-Related Errors

Type Conformance

Error: Type 'XYZ' does not conform to the 'Sendable' protocol​

It means that a type (often a class or a reference type) is being used in a concurrent context but hasn’t been marked as safe to share across threads.

Several solutions:

  • Conform to Sendable: If you know the type is actually safe to use from multiple threads (e.g. it’s immutable or internally synchronized), you can conform to Sendable.

  • Isolate to an actor: If the type isn’t inherently thread-safe, consider isolating it to an actor or marking it @MainActor if it should only be used on the main thread.

  • Redesign usage: In some cases, you might avoid needing the type across threads. For example, rather than capturing a non-Sendable class inside an async task, perform the needed work on that class on its own queue or actor, or copy the necessary data out into a Sendable form (like a struct) to send to the task, or capture it in the closure capture list.

Non-sendable crossing boundaries

Error: Passing argument of non-sendable type 'Foo' outside of actor-isolated context may introduce data races

This diagnostic appears when you attempt to use a non Sendable value in a way that exits an actor’s protection. In other words, if a type is private to the isolation context of an actor there can’t be data races. But if you send it outside the actor it needs to be Sendable.

Solutions:

  • Mark the type as Sendable: See elsewhere on this guide how.
  • Keep usage on the actor
  • Isolate Foo to a global actor: Another strategy is marking the non-sendable type with a global actor (like @MainActor if appropriate). This way, even when used in async calls, it’s still confined to a single actor. For instance, if Foo is only ever used on the main thread, mark it @MainActor – then the call is actor-to-actor and no cross-actor send happens.

Weak var is mutable

Error: Stored property 'delegate' of 'Sendable'-conforming class 'x' is mutable

Delegates need to be mutable because they may change at runtime. This makes your type non Sendable. Using an actor is one way to solve it:

actor PipelineDelegateHolder {
    weak var delegate: PipelineDelegate?

    nonisolated init(delegate: PipelineDelegate?) async {
        self.delegate = delegate
    }

    func callDelegate(_ action: (PipelineDelegate) -> Void) {
        if let delegate = delegate {
            action(delegate)
        }
    }
}

final class TransPipeline: Sendable {
    let delegateHolder: PipelineDelegateHolder

    init(delegate: PipelineDelegate) async {
        self.delegateHolder = await PipelineDelegateHolder(delegate: delegate)
    }
}

// Usage
await delegateHolder.callDelegate { delegate in
    delegate.pipeline(self, didUpdateApples: apples)
}

By using this solution we are adding suspension points in both initializers and delegate calls. Also, the PipelineDelegate needs to be Sendable. If you want a structured concurrency ‘safe’ solution this is it.

Sending value risks causing data races

From Holy Borla’s sending-risks-data-race.md.

The following example calls a non-isolated function from the main actor:

class Person {
    var name: String = ""    
    func printNameConcurrently() async {
        print(name)
    }
}

@MainActor
func onMainActor(person: Person) async {
    // calling non-isolated function from the main actor
    await person.printNameConcurrently()
}

Non-sendables (like person) can only be accessed from one isolation domain at a time. Otherwise, who is to say main actor won’t change person while printNameConcurrently() is running?.

Solution:

@execution(caller)
func printNameConcurrently() async {
    print(name)
}

Now printNameConcurrently() runs in the isolation domain of the caller. If called from main, it runs on main.

As of march 2025 this change is not yet implemented. See Run nonisolated async functions on the caller’s actor by default. So for the time being follow the doctor’s advice if it hurts don’t do it. Instead, make person a Sendable, move it to @MainActor, etc.

Sending closure risks causing data races

From Holy Borla’s sending-closure-risks-data-race.md.

class MyModel {
  var count: Int = 0

  func perform() {
    Task {
      // đŸ’„ error: passing closure as a 'sending' parameter risks causing data races
      self.update()
    }
  }

  func update() { count += 1 }
}

This code has two isolation domains:

  • MyModel (nonisolated)
  • Task {}

Task { self
 } contains a reference to self (MyModel) which is now accessible from two domains. Again: non-sendables (like person) can only be accessed from one isolation domain at a time.

Solution

  • Make MyModel an actor (or isolate to a global one). Actors are Sendable because any code touching them is required to switch to the actor isolation domain. Therefore the actor is only accessible from one (1) isolation domain: its own.
  • Another solution is to really send (sending) the value:
class MyModel {
    static func perform(model: sending MyModel) {
        Task {
            model.update()
        }
    }
    
    func update() { ... }
}

This works because it enforces single-ownership semantics:

  • The model parameter is annotated sending, meaning: the callsite can’t touch this instance again after sending it.
  • perform is an static so you know it can’t capture self in the Task closure

Captures in a @Sendable closure

From Holy Borla’s sendable-closure-captures.md.

func callConcurrently(
  _ closure: @escaping @Sendable () -> Void
) { ... }

func capture() {
    var result = 0
    result += 1
    
    callConcurrently {
        // đŸ’„ error: reference to captured var 
        // 'result' in concurrently-executing code
        print(result)
    }
}

You annotated a closure as Sendable but that closure captures non-Sendable values. This just in: non-sendables can only be accessed from one isolation domain at a time?, news at 5.

Solutions:

  • If it is a value (struct, enum) copy it in the closure capture list to avoid passing a non-Sendable self.
  • Or make self sendable (make it an actor, annotate with global actor, or @unchecked Sendable with internal synchronization)

Global state

Same as Holy Borla’s Unsafe mutable global and static variables

Error: Var 'xxx' is not concurrency-safe because it is non-isolated global shared mutable state

This error flags a global variable (or a static) that is mutable and not isolated to any actor. Swift 6 enforces stricter rules for global state​: a global var must either be made immutable or be protected by an actor. If not, it’s considered a potential data race, since any thread could access that global.

The Swift evolution proposal SE-0412 Strict concurrency for global variables details several solutions:

  • Use a global actor: Mark the global with an actor, commonly @MainActor if it’s related to UI or main thread, or define a custom @globalActor for it. For example: @MainActor var myGlobal = 0. This ensures all access to myGlobal is on the main actor (or your chosen actor), preventing unsynchronized access. Keep in mind you’ll need await to access it from other contexts

  • Make it immutable: If possible, use let instead of var, and ensure the type is Sendable. An immutable constant of a Sendable type is safe by definition (no races since it never changes)​. This might not be feasible if the value truly needs to change, but sometimes a redesign (e.g., use of a singleton actor to hold the state instead of a global var) can eliminate the need for a mutable global.

  • Mark as nonisolated(unsafe): This attribute explicitly opts out of concurrency checking for that variable. For example: nonisolated(unsafe) var myGlobal = 0. This will silence the error​, but at your own risk. Only do this if you fully understand the implications and perhaps synchronize access.

An example of this problem is when a SDK protocol requirement conflicts with Structured Concurrency:

struct ButtonFramePreferenceKey: PreferenceKey {
    // đŸ’„ Static property 'defaultValue' is not concurrency-safe because it is nonisolated global shared mutable state
    static var defaultValue: CGRect = .zero
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }
}

I can’t change the SDK so I add @preconcurrency:

@MainActor
private struct ButtonFramePreferenceKey: @preconcurrency PreferenceKey {
    static var defaultValue: CGRect = .zero
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }
}

Singleton Pattern

Swift 6’s strict concurrency checking flags singleton patterns as potential data race risks. Here’s a common error:

Error: Static property 'shared' is not concurrency-safe because 
       non-'Sendable' type 'YourType' may have shared mutable state

This error appears when you have a singleton (static shared instance) of a class that isn’t properly isolated or made Sendable. Example:

final class UserLanguage {
    // đŸ’„ Static property 'shared' is not concurrency-safe because 
    // non-'Sendable' type 'UserLanguage' may have shared mutable state
    static let shared = UserLanguage()
    private init() {}
}

Note the “may have shared mutable state” even when no mutable state exists. The compiler is overzealous with classes, flagging a potential error for mutable state that may be added in the future.

Solutions

Actor-based Singleton

actor UserLanguage {
    static let shared = UserLanguage()

    var current: String {
        didSet {
            UserDefaults.standard.set(current, forKey: "userLanguage")
        }
    }

    private init() {
        if let savedLanguage = UserDefaults.standard.string(forKey: "userLanguage") {
            self.current = savedLanguage
        } else {
            let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en"
            self.current = systemLanguage
        }
    }
}

This approach is best when:

  • You need high-performance thread-safety
  • You expect frequent concurrent access from multiple threads
  • You’re comfortable with async/await in your codebase

MainActor Isolation

@MainActor
final class UserLanguage {
    static let shared = UserLanguage()
    ...
}

This approach is best when:

  • The singleton is primarily used for UI-related tasks
  • You want to ensure all access happens on the main thread
  • You want to avoid extensive rewriting of synchronous code

Internally Synchronized Sendable

final class UserLanguage: @unchecked Sendable {
    static let shared = UserLanguage()
    
    private let lock = NSLock()
    
    private var _current: String
    
    var current: String {
        get {
            lock.lock()
            defer { lock.unlock() }
            return _current
        }
        set {
            lock.lock()
            defer { lock.unlock() }
            _current = newValue
            UserDefaults.standard.set(_current, forKey: "userLanguage")
        }
    }
    ...
}

This approach is best when:

  • You need to maintain synchronous access patterns
  • You want to avoid actor isolation or main thread constraints
  • You’re willing to implement proper internal synchronization

Choosing the Right Approach

  • Actor: Best for high-performance concurrency needs
  • @MainActor: Simplest solution for UI-related singletons
  • @unchecked Sendable with locks: Most compatible with existing synchronous code

Remember that the key principle is ensuring that shared mutable state can only be accessed from one isolation domain at a time.

Concurrency

Error: Instance method 'lock' is unavailable from asynchronous contexts; 
       Use async-safe scoped locking instead

You are attempting to await a lock. Example:

extension NSLock {
    func withLock<T>(_ work: () async throws -> T) async rethrows -> T {
        await lock()
        defer { unlock() }
        return try await work()
    }
}

Probably because you wanted to avoid actor reentrancy and execute the content of the method atomically.

Consider the case of an audio recorder that stores recordings on disk and properties on Core Data. Maybe you created an async file and Core Data APIs but now you want both to run atomically. For instance, delete on Core Data and then on disk without a concurrent call seeing an intermediate state. How do you do that?

If you have a code path that you want to:

  • Execute fully (without reentrant calls): put it inside an actor and don’t spawn additional async calls.
  • Execute on main: annotate it with @MainActor and avoid spawning extra async calls to code not protected by @MainActor –also valid with any global actor.
  • Restrict concurrency using a token bucket like TokenBucket.swift or a AsyncSemaphore.

How does it work?

  • An actor prevents parallel execution (two active threads running the same code concurrently), but not concurrent scheduling (the code can suspend and re-enter).
  • @MainActor ensures code runs on main, but async calls to unprotected code may suspend your main-actor code, run the unprotected code on another thread, and then re-enter your main-actor code.
  • The token bucket limits the number of concurrent executions like a semaphore does, but instead of blocking, we can suspend and store tasks as continuations TokenBucket.swift. An alternative solution is AsyncSemaphore, that uses a lock inside, but safely and without warnings.

I wrote more about the TokenBucket and locks in Swift Synchronization Mechanisms.