Back to Blog

Swift 6 strict concurrency migration: the staged plan that won’t brick your app

A practical migration plan for Swift 6 strict concurrency: baseline warnings, isolate boundaries, fix Sendable issues, and tighten checks without stalling delivery.

10 min read

Swift 6 concurrency migration goes bad for one predictable reason: teams flip the strictness switch before they have mapped where state lives, which code must stay on the main actor, and which APIs are still dragging callback-era assumptions through the codebase.

That is how you turn a real upgrade into a quarter-long yak shave.

The safer approach is staged. You tighten the system in layers, keep the app shipping, and make each step small enough to verify.

This is the plan I trust on real products.

1) Start with a baseline, not heroics

Before changing strictness levels, capture the current shape of the problem.

You want to know:

  • which modules produce the most concurrency warnings
  • which warnings are repeated patterns versus one-off messes
  • which warnings sit in app code versus third-party boundaries
  • whether your biggest risks are actor isolation, Sendable, or legacy callback APIs

Do not begin by fixing warnings in random order. That is how teams burn a week and learn nothing.

Instead:

  1. build the app with concurrency warnings visible in CI
  2. export or save the warning list
  3. group issues by type and module
  4. pick the smallest high-leverage boundary first

In practice, the first pass usually reveals three buckets:

  • UI state that should obviously be @MainActor
  • shared mutable services that need actor isolation or confinement
  • data types and closures that now need Sendable thinking

That classification matters because the fixes are different.

2) Turn on diagnostics before full strictness

Your first goal is signal, not pain.

If you jump straight to full Swift 6 checking across everything, you get a fire hose. Some warnings are real bugs. Some are old design debt. Some are boundaries that need a wrapper, not a rewrite.

Use a staged compiler configuration so you can see the failures while still delivering.

A useful migration shape looks like this:

  • enable strict concurrency warnings in one target or package first
  • keep the rest of the app building
  • fix repeated patterns and write them down
  • expand the boundary once the fixes become boring

The exact build settings differ by project setup, but the operating principle does not: tighten the smallest surface that gives you honest feedback.

If your app is modular, do this per package or feature module. If it is a monolith, start with the most stable leaf area, not the churn-heavy feature of the week.

3) Mark actor boundaries aggressively where intent is already clear

A surprising amount of migration pain disappears once you stop being vague about execution context.

If a type owns view state, navigation state, or anything the UI reads directly, put it on the main actor unless you have a strong reason not to.

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published private(set) var user: User?
    @Published private(set) var isLoading = false

    private let client: APIClient

    init(client: APIClient) {
        self.client = client
    }

    func load(userID: String) async {
        isLoading = true
        defer { isLoading = false }

        do {
            user = try await client.fetchUser(id: userID)
        } catch {
            // map and surface error
        }
    }
}

That is not over-annotation. It is a statement of ownership.

What hurts teams is the half-in, half-out middle ground where UI-facing objects mutate state from unclear contexts and every call site starts needing band-aids.

A few rules keep this sane:

  • UI state types default to @MainActor
  • pure value types stay nonisolated
  • services that coordinate shared mutable state should not live on the main actor just because it is convenient

If you do this early, later warnings become much easier to reason about.

4) Fix shared mutable state by changing ownership, not by sprinkling keywords

This is where most migrations either get clean or get cursed.

If multiple tasks touch the same mutable state, you need one owner. In Swift concurrency, that usually means one of three things:

  • the state lives inside an actor
  • the state is confined to one actor, often the main actor
  • the state stops being shared mutable state at all

Do not start with @unchecked Sendable. That label is a debt instrument. Use it only when you can explain the invariant in one sentence and prove it in code review.

A typical service refactor looks like this:

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

    func image(for url: URL) -> UIImage? {
        storage[url]
    }

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

This is boring in exactly the right way.

If you instead keep the dictionary in a class and fight warnings at every use site, the compiler is doing you a favor by being annoying.

When actor conversion feels too expensive, ask the real question: is the service actually shared, or did convenience slowly turn it into a global dumping ground? A migration is a good time to shrink surfaces, split responsibilities, and delete fake abstractions.

5) Triage Sendable issues by category

Not all Sendable warnings deserve the same response.

Split them into four buckets:

Value types that should obviously be Sendable

Most immutable structs with Sendable members can just conform.

struct UserSummary: Sendable {
    let id: String
    let displayName: String
    let isPro: Bool
}

Easy win. Take it.

Reference types that should not cross concurrency domains

If a class is mutable and shared casually, the fix is rarely “make it Sendable”. The fix is to stop passing it across boundaries.

Common moves:

  • convert it to a struct snapshot for transfer
  • isolate it to an actor
  • expose methods instead of exposing the mutable object

Closure captures that leak non-sendable state

A lot of noise comes from task bodies capturing self or mutable members that were never meant to cross domains.

Instead of this:

Task {
    logger.log(event)
    self.items.append(item)
}

prefer explicit isolation or copied values:

let logger = logger
let event = event

Task { @MainActor in
    logger.log(event)
    items.append(item)
}

Or better, move the mutation into an isolated method so the task body is not doing improvisational surgery.

Third-party and framework boundaries

Sometimes the problem is not your model. It is the old API shape.

Wrap the boundary once. Do not smear workaround code across the app.

That wrapper becomes the quarantine wall between modern concurrency and legacy behavior.

6) Bridge callback APIs early, because they poison call sites

Legacy callbacks infect design upstream. Once they remain in place, every caller keeps carrying continuation glue, escape hatches, and threading ambiguity.

Wrap them once with async APIs and move on.

func fetchAvatar(url: URL) async throws -> UIImage {
    try await withCheckedThrowingContinuation { continuation in
        imageLoader.load(url) { result in
            switch result {
            case .success(let image):
                continuation.resume(returning: image)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

The benefit is bigger than syntax.

Now the call site can participate in cancellation, actor isolation, and task composition without hauling callback baggage behind it.

While doing this, check one thing carefully: whether the old API can call back multiple times or from inconsistent queues. If it can, the wrapper must enforce the contract. Otherwise you have simply hidden the bug in a nicer shape.

7) Stop fighting the compiler on UI updates

A lot of migration friction is self-inflicted.

Teams try to keep async work “off the main thread” in a vague way, then manually hop around, then wonder why isolation warnings multiply.

The right question is not “how do I avoid the main actor?”

The right question is “which parts must be isolated to the main actor, and which parts are pure work that can happen elsewhere?”

A clean split looks like this:

  • networking, decoding, persistence, and CPU work off the main actor
  • view models and observable UI state on the main actor
  • small, explicit handoff points between them

That gives you fewer warnings and code that actually explains itself.

8) Migrate module by module, then lock the door behind you

Once one module is clean, do not leave it half-protected.

Add a gate:

  • keep concurrency diagnostics enabled there
  • fail CI on new violations in that module
  • document the patterns that got the module clean

This matters because otherwise the codebase behaves like a haunted house. You clean one room, then someone opens a side door and the same mess drifts back in.

A migration sticks only when fixes become policy.

Useful things to document after the first clean module:

  • when a type should be @MainActor
  • when to introduce an actor
  • when Sendable conformance is expected
  • when @unchecked Sendable is allowed and what proof it needs
  • how to wrap callback APIs consistently

These rules do not need a manifesto. A short engineering note in the repo is enough.

9) Be careful with nonisolated and escape hatches

Swift gives you ways to reduce friction. That does not mean they are free.

Two common traps:

  • using nonisolated because the compiler complained, not because the member is truly safe outside isolation
  • using @unchecked Sendable as a sedative for warnings you have not understood

Treat both as last-mile tools.

Before using either, ask:

  • what data is being protected
  • who can mutate it
  • what invariant makes this safe across concurrency domains
  • how would another engineer verify that claim in five minutes

If you cannot answer that cleanly, the shortcut is probably hiding a design issue.

10) Roll out strictness like a product change

This part gets ignored, then teams act surprised when the migration destabilizes delivery.

Handle it like any other risky system change:

  • define the rollout order by module or target
  • reserve time for cleanup, not just compiler appeasement
  • keep a visible checklist of remaining warning classes
  • review patterns in code review so the team converges
  • avoid mixing huge feature work with the hardest migration phase

The goal is not just “zero warnings”. The goal is predictable engineering flow after the migration lands.

If the team still argues on every PR about actor boundaries and sendability, you are not done. You have merely moved the fight from the compiler to humans, which is slower and dumber.

A staged migration plan you can actually run

If you want the shortest version, use this sequence:

  1. capture a warning baseline
  2. pick one stable module or feature slice
  3. mark obvious UI-facing types as @MainActor
  4. isolate shared mutable services with actors or confinement
  5. fix low-risk Sendable value types
  6. wrap callback-based dependencies in async APIs
  7. add CI protection for the cleaned area
  8. repeat until the full app is covered
  9. only then tighten the global strictness level fully

That sequence works because each step removes ambiguity before adding pressure.

Swift 6 strict concurrency is not mainly a syntax migration. It is a design cleanup with compiler enforcement. Treat it that way and it becomes manageable. Treat it like a giant warning suppression campaign and it will chew through your sprint and still leave races in the walls.

The compiler is not being dramatic. It is showing you where ownership was fuzzy all along.