Home

Turning on strict concurrency in a four-year-old app

Switching an existing app to strict concurrency checking produced 812 warnings. My first instinct was to turn it back off, and my second was to add @unchecked Sendable everywhere, which is the same thing with extra steps.

What actually worked was doing it in a specific order, over about three weeks, and accepting that most of the warnings were telling me something true.

Turn it on gradually

The setting has levels, and they exist so you do not have to face everything at once:

// Package.swift
swiftSettings: [
    .enableUpcomingFeature("StrictConcurrency")
]

In Xcode, Strict Concurrency Checking has three values:

  • Minimal — only what is explicitly marked. This is roughly the old behaviour.
  • Targeted — checks code that already opted into concurrency. The right starting point.
  • Complete — checks everything. The destination.

Start at Targeted, get to zero, then move to Complete. Going straight to Complete on a large codebase produces a warning list nobody can act on.

Do it module by module

If the app is modularised, turn it on for one module at a time, starting at the bottom of the dependency graph. A Models package with no dependencies is a hundred small fixes and no architectural questions; getting it to zero means the module above it starts from a clean base rather than inheriting warnings.

Doing this in the other order — the app shell first — means every warning is entangled with types you have not fixed yet, and it is impossible to tell which are real.

The two changes that removed most of the warnings

@MainActor on view models and UI types. About 400 of my 812 warnings were mutable state that was only ever touched on the main thread but had never been marked as such.

@MainActor
final class ProfileViewModel {
    var name = ""
    var isLoading = false
}

That one annotation removes the warnings and makes an informal rule into an enforced one. It also propagates usefully — calling a @MainActor method from a non-isolated context becomes an error, which is exactly the bug you wanted to know about.

Sendable on value types. Most of the rest were structs and enums that were already thread-safe by construction and simply needed to say so. Many did not even need the annotation — a struct whose properties are all Sendable gets the conformance synthesised, so the fix was often making one property let or changing a class field to a struct.

Between those two, roughly 700 of the 812 were gone.

Warning

Resist @unchecked Sendable while doing this. It silences the warning without making anything safe, and because it compiles cleanly you will never come back to it. I marked fifteen types @unchecked in the first afternoon and had to audit every one of them later — going through them properly took less time than the audit did.

The genuinely hard ones

The remaining hundred were real problems, and worth the time.

Global mutable state. static var shared on a singleton is a data race that has always been there. The fix is a let if it is immutable, @MainActor if it is UI-adjacent, or an actor if it is genuinely shared mutable state.

Delegate callbacks from arbitrary queues. An old URLSession delegate or a C library callback arrives on a thread the compiler cannot reason about. The fix is to hop explicitly at the boundary:

nonisolated func didReceive(_ data: Data) {
    Task { @MainActor in
        self.buffer.append(data)
    }
}

Completion handlers capturing mutable state. These were mostly genuine races that had been working by luck, and several of them explained crash reports I had never been able to reproduce.

Third-party libraries without Sendable annotations. The frustrating category, because the fix is not yours to make. @preconcurrency import SomeLibrary suppresses warnings originating from that module, which is the right tool — it is scoped to the actual problem rather than disabling checking generally.

Was it worth it

Yes, for one specific reason: three of the warnings were bugs I already knew about and could not reproduce. Two intermittent crashes and one “the list occasionally shows stale data” report, all explained by races the compiler pointed at directly.

The rest of the value is preventative and harder to measure. But the codebase is now one where adding a background operation cannot silently introduce a data race, and that changes how much care every future change needs.

What I would do differently

Start with Targeted, not Complete. I began at Complete, got 812 warnings, and lost two days to being overwhelmed before restarting at Targeted.

Fix the @MainActor annotations first, all of them, before anything else. It is the largest single category by a wide margin, it is mechanical, and it makes the remaining warnings much easier to read because the UI layer stops generating noise.

Do not batch the commits. One module per commit, or one category per commit. A 400-file “concurrency migration” commit is unreviewable and impossible to bisect when something breaks.