Back to Blog

Sendable pitfalls: what actually breaks, and how to redesign safely

Swift 6 surfaces Sendable problems exactly where values cross isolation boundaries. Here is what usually breaks, how to classify each error, and how to redesign ownership without hiding the problem behind @unchecked Sendable.

10 min read

Sendable is not the compiler being dramatic.

It is a boundary check.

The moment a value crosses between concurrent tasks, actors, or global actors, Swift needs to know whether sharing that value can create a data race. If the answer is unclear, you get a diagnostic.

That is why Sendable errors feel annoying at first and useful later. They show you the exact point where ownership is fuzzy, mutation is leaking, or an old reference type is being passed around like it is harmless when it is not.

The worst response is to slap @unchecked Sendable on the type and move on. That silences the warning, but it also turns off the one guardrail that was trying to keep your state coherent.

The better response is to classify the failure correctly, then redesign the smallest boundary that fixes it.

What Sendable is actually checking

A type is Sendable when it is safe to transfer across concurrency domains.

In practice, that usually means one of three things:

  1. it is a value type whose stored properties are also sendable
  2. it is immutable and does not expose shared mutable state
  3. it is isolated behind an actor or another mechanism that makes the safety invariant explicit

The important detail is that Sendable is not about whether your code has crashed yet. It is about whether the compiler can prove that concurrent access will not create a race.

That distinction matters because a lot of code looks fine in light testing and still has broken ownership.

What usually breaks first

Most Sendable failures fall into a small set of patterns.

1. Mutable reference types crossing task boundaries

This is the classic one.

final class UploadState {
    var progress: Double = 0
    var retryCount: Int = 0
}

func startUpload(state: UploadState) {
    Task {
        state.progress = 0.5
    }
}

UploadState is a mutable class. Once you capture it in a task, Swift cannot prove that some other code is not mutating the same instance at the same time.

The fix is not “make the class sendable somehow.”

The real fix is to decide what this state is:

  • If it is UI state, isolate it to @MainActor
  • If it is shared mutable coordination state, move it behind an actor
  • If it is just input data, make it a struct and pass a snapshot

2. Closures that capture non-sendable dependencies

A lot of code looks clean until a closure becomes @Sendable.

final class AnalyticsClient {
    func track(_ event: String) {}
}

func makeHandler(client: AnalyticsClient) -> @Sendable (String) -> Void {
    { event in
        client.track(event)
    }
}

The closure is marked @Sendable, which means everything it captures must also be safe to send. A mutable class dependency usually is not.

This is where teams start fighting the compiler for no reason. The compiler is right. A sendable closure is a promise that it can run concurrently without racing captured state.

You fix it by tightening the ownership model:

  • capture an immutable value instead of the whole object
  • isolate the dependency to an actor and call it asynchronously
  • keep the closure non-sendable if it never needs to cross a concurrency boundary

@Sendable should describe reality, not aspiration.

3. Protocol erased dependencies with unclear thread safety

This is common in app architecture.

protocol TokenStore {
    func token() -> String?
    func save(_ token: String)
}

struct APIClient: Sendable {
    let tokenStore: any TokenStore
}

This fails for a good reason. The existential tells Swift nothing about whether the concrete implementation is safe to share concurrently.

There are a few honest fixes:

  • make the protocol refine Sendable if that matches the design
  • isolate the implementation behind an actor
  • stop storing a live dependency and pass the specific data needed for the operation

What you should not do is bolt @unchecked Sendable onto the client just because the existential is inconvenient.

4. Legacy SDK objects that were never designed for sendability

Delegates, observers, formatters, NSManagedObject, and a pile of UIKit and Foundation reference types often do not belong in sendable code paths.

That does not mean Swift Concurrency is unusable. It means your boundary is wrong.

Typical fixes:

  • convert framework objects to plain value snapshots before crossing the boundary
  • isolate framework-bound code to @MainActor when it is truly UI-bound
  • wrap mutable coordination in a focused actor instead of passing the object everywhere

The compiler is often exposing a design issue that already existed.

The redesign order that causes the least damage

When a codebase starts surfacing Sendable errors, teams often jump straight to annotations. That is backwards.

Use this order instead.

1. Identify the isolation boundary first

Ask one blunt question: why is this value crossing a concurrency boundary at all?

Common answers:

  • a Task closure captured it
  • an actor method accepted it
  • a non-main context touched @MainActor state
  • a sendable callback closed over it

Until that boundary is clear, every fix is guesswork.

2. Prefer value snapshots over shared objects

If a worker only needs data, pass data.

Bad:

func generateReport(from session: UserSession) async

Better:

struct ReportInput: Sendable {
    let userID: String
    let locale: String
    let plan: String
}

func generateReport(from input: ReportInput) async

This is the cleanest fix because it removes shared mutable state from the equation entirely.

3. Isolate the true owner of mutable state

If the state genuinely needs to be shared and mutated by overlapping tasks, give it one owner.

For UI state, that owner is often @MainActor.

For background coordination, that owner is often an actor.

A good rule: do not make an entire subsystem an actor because one dictionary inside it is shared. Isolate the dictionary owner, not the whole planet.

4. Make sendable closures capture sendable things

If an API requires @Sendable, treat the capture list like a design review.

Bad capture lists usually reveal one of these smells:

  • a giant service object doing too much
  • mutable class state leaking into background work
  • UI objects being touched from the wrong context

A small immutable configuration struct is easy to send. A half the app singleton is not.

5. Use @unchecked Sendable only with a written invariant

Sometimes you really do have a reference type that is safe to share because access is internally synchronized.

Fine. Then prove it.

For example:

final class LockedBox<T>: @unchecked Sendable {
    private let lock = NSLock()
    private var value: T

    init(_ value: T) {
        self.value = value
    }

    func withValue<R>(_ body: (inout T) -> R) -> R {
        lock.lock()
        defer { lock.unlock() }
        return body(&value)
    }
}

That annotation is still a tradeoff, not a victory. The safety invariant is: all access to value must go through withValue(_:).

If that invariant is not documented and enforced, @unchecked Sendable is just a polite lie.

A practical refactor: from non-sendable cache to isolated owner

Suppose you start here:

final class ImageCache {
    private var storage: [URL: Data] = [:]

    func value(for url: URL) -> Data? {
        storage[url]
    }

    func insert(_ data: Data, for url: URL) {
        storage[url] = data
    }
}

struct ImageLoader: Sendable {
    let cache: ImageCache

    func load(_ url: URL) async throws -> Data {
        if let cached = cache.value(for: url) {
            return cached
        }

        let (data, _) = try await URLSession.shared.data(from: url)
        cache.insert(data, for: url)
        return data
    }
}

ImageLoader claims to be sendable, but it stores a mutable reference type with unsynchronized state. That is exactly the bug Swift is trying to stop.

The smallest honest redesign is not to force the cache into conformance. It is to isolate the cache.

actor ImageCache {
    private var storage: [URL: Data] = [:]

    func value(for url: URL) -> Data? {
        storage[url]
    }

    func insert(_ data: Data, for url: URL) {
        storage[url] = data
    }
}

struct ImageLoader: Sendable {
    let cache: ImageCache

    func load(_ url: URL) async throws -> Data {
        if let cached = await cache.value(for: url) {
            return cached
        }

        let (data, _) = try await URLSession.shared.data(from: url)
        await cache.insert(data, for: url)
        return data
    }
}

Now the ownership model matches reality:

  • the loader can be shared freely
  • the mutable cache has one isolated owner
  • concurrent callers cannot race through the backing dictionary

That is the pattern to look for. Do not ask, “How do I shut the compiler up?” Ask, “Who should own this mutable state?”

When @MainActor is the right fix

@MainActor is not a magic solvent for concurrency errors, but it is correct when the state is inherently tied to the UI.

For example, a view model that drives SwiftUI rendering should usually not be made Sendable. It should be main-actor isolated.

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published private(set) var name: String = ""

    func reload(using client: APIClient) async throws {
        name = try await client.fetchName()
    }
}

That works because the ownership rule is real: this state belongs on the main actor.

The bad fix would be throwing Sendable at the view model and then pretending UI state is safe to share arbitrarily.

Use @MainActor when the type is UI-bound. Use actors when the type coordinates shared mutable state off the main actor. Use structs when you just need data.

That split keeps the model honest.

A migration strategy that stays reviewable

If you are moving a real app to Swift 6 strict concurrency, the order of operations matters.

A sane loop looks like this:

  1. turn on the diagnostics and build
  2. group errors by pattern, not by file count
  3. fix the obvious value-type and snapshot cases first
  4. isolate the real state owners next
  5. leave @unchecked Sendable for the small leftovers that have a proven invariant
  6. rebuild after each category and keep the commits small

Why this works:

  • value and snapshot fixes usually shrink the problem quickly
  • actor isolation becomes clearer once trivial sharing is gone
  • dangerous escape hatches stay visible instead of spreading quietly through the codebase

The teams that suffer most are the ones that mix annotation sprawl, random actor conversions, and giant migration PRs.

Small, boring, verified changes win.

Practical checklist

When a Sendable diagnostic shows up, run this list:

  • What boundary is being crossed?
  • Is the value really data, or is it shared mutable state?
  • Can I pass a sendable snapshot instead?
  • If the state is shared, who should own it?
  • Is this UI state that belongs on @MainActor?
  • If I am considering @unchecked Sendable, what exact invariant makes it safe?

That checklist is dull, which is exactly why it works. Concurrency bugs love vague thinking.

Swift is forcing the issue in the right place.

Sendable errors are not random friction. They are architecture feedback.

Treat them that way, and the fix is usually smaller than it first appears.