Migrating a Real App to Swift 6 Strict Concurrency: The Sendable and @MainActor Errors You'll Hit
- Author
- Ehsan Azish · 3NSOFTS
- Updated
- June 2026
- Read time
- 18 min read
- Level
- Intermediate → Senior
- Platform
- Swift concurrency basics, async/await, an existing codebase
Implementation Notes
- ~/ What broke: Swift 6 turned hidden data races into compiler errors.
- ~/ What to do: Separate UI state, actors, Sendable models, and legacy adapters into migration slices.
The Swift 6 language mode turns data-race safety from advice into compiler-enforced law. On a fresh project that's pleasant. On a shipped codebase it's a wall of errors that all sound the same and don't tell you what to actually do. This guide is the pragmatic path: the errors you'll hit, what each one means, the order to fix them in, and which escape hatches are safe versus which ones just hide the bug.
Don't flip the switch first
The single biggest mistake: setting the Swift 6 language mode on the whole project and drowning in hundreds of simultaneous errors. Migrate incrementally instead:
- Stay in Swift 5 mode and turn on complete concurrency checking as warnings first. Now the compiler shows you every future error as a warning you can triage at your own pace.
- Migrate module by module (or target by target) to Swift 6 mode, smallest/leafmost first. Dependencies before dependents.
- Only flip the whole app once the warnings are gone.
This converts a wall into a queue.
The errors, decoded
"Type 'X' does not conform to the 'Sendable' protocol"
What it means: You're passing X across a concurrency boundary (into a Task, across an await, between actors) and the compiler can't prove it's safe to share.
What to actually do, in order of preference:
- If
Xis immutable value-like (onlyletproperties ofSendabletypes): mark itSendable. Often it already qualifies and you just declare it.struct UserProfile: Sendable { // all-let, all-Sendable members let id: UUID let name: String } - If
Xis a reference type that's genuinely confined to one actor: isolate it to that actor (e.g.@MainActor final class) rather than making itSendable. - If
Xis a final class with internal locking that you've verified is thread-safe:@unchecked Sendable— but only with a comment explaining the synchronization. This is a promise to the compiler; an undeserved one reintroduces the race.
The wrong move is reflexively slapping @unchecked Sendable on everything to silence errors. That's not migrating to safety; it's disabling the check.
"Main actor-isolated property cannot be referenced from a nonisolated context"
What it means: You touched UI/@MainActor state from a background context (a detached task, a nonisolated method, a callback on an arbitrary queue).
What to actually do: Hop to the main actor explicitly.
func handleResult(_ data: Data) async {
let parsed = parse(data) // fine off-actor
await MainActor.run {
self.viewState = .loaded(parsed) // UI mutation on the main actor
}
}
Or, if the whole method should run on the main actor, annotate it @MainActor rather than hopping inside it.
"Capture of 'self' with non-Sendable type in '@Sendable' closure"
What it means: A Task { } or other @Sendable closure captured self, and self's type isn't safe to send.
What to actually do: Decide where self lives. If it's a view model driving UI, make the class @MainActor — then Task { } inside its methods inherits main-actor isolation and the capture is fine. If it genuinely needs off-actor work, extract that work into a Sendable function that takes only the values it needs, and don't capture self at all.
A clean default that resolves most errors
For app code, the highest-leverage decision is: make your view models and UI-facing types @MainActor by default, and push only the genuinely parallel work (network, parsing, inference) into async functions or actors.
@MainActor
final class FeatureViewModel: ObservableObject { // or @Observable
@Published private(set) var state: State = .idle
func load() async {
state = .loading
// Off-actor work returns Sendable values; UI mutation stays on @MainActor.
let result = await repository.fetch() // repository is an actor or Sendable
state = .loaded(result)
}
}
This single pattern — UI on the main actor, parallel work behind async/actors returning Sendable values — dissolves the majority of the @MainActor and capture errors, because the isolation boundaries become explicit and consistent.
Actors for shared mutable state
When you have genuinely shared mutable state accessed concurrently (a cache, a connection pool, an inference pipeline), an actor is the right tool — it serializes access without manual locking:
actor ModelCache {
private var cache: [String: Model] = [:]
func model(for key: String) -> Model? { cache[key] }
func store(_ model: Model, for key: String) { cache[key] = model }
}
Callers await into the actor; the compiler guarantees no two contexts mutate cache simultaneously. This is how the AI/inference code in this series stays race-free under strict concurrency.
The escape hatches, ranked
From safest to most dangerous:
@MainActorannotation — not an escape hatch, the correct fix for UI types.actorisolation — correct fix for shared mutable state.Sendableconformance on genuinely-immutable types — correct.nonisolatedon methods that truly touch no isolated state — fine when accurate.@unchecked Sendable— only with verified synchronization and a comment. A promise you must keep.@preconcurrency import— pragmatic for third-party modules not yet updated; quarantines their warnings while you migrate your own code.
Anything below #4 is a place you're telling the compiler "trust me." Earn that trust or the data race you were warned about ships anyway.
Production checklist
- Migrate incrementally — warnings first in Swift 5 mode, module by module, leaves first.
@MainActoryour view models and push parallel work behindasync/actors.- Use
actorfor shared mutable state, not manual locks or@unchecked Sendable. - Reserve
@unchecked Sendablefor verified-safe types, always commented. @preconcurrency importto quarantine un-migrated dependencies.- Never silence errors reflexively — each one is a real boundary to make explicit.
Why this matters for shipped apps
Strict concurrency errors feel like the compiler being pedantic, but each one marks a place where two pieces of code could touch the same state at once — exactly the bugs that are nondeterministic, unreproducible, and brutal in production. Migrating properly (explicit isolation, actors for shared state, @MainActor for UI) doesn't just satisfy the compiler; it eliminates a whole class of crashes. Migrating lazily (@unchecked Sendable everywhere) keeps the bugs and hides the warnings.
This is foundational for any AI feature, where inference runs off the main actor and results flow back to UI. We architect that boundary correctly as part of our Swift 6 AI integration and architecture audit work at 3NSOFTS.