iOS Engineering

Swift 6 strict concurrency in real iOS apps: 7 refactors teams keep repeating (without exploding the diff)

Swift 6 strict concurrency tends to fail at the same SwiftUI boundaries: view model initialisation, navigation, delegates, and “just one cache”. Here are seven repeatable refactors that keep correctness and code review manageable.

Swift 6 strict concurrency breaks in the same places in most shipped iOS apps: SwiftUI view model initialisation, @MainActor boundaries, and the “harmless” shared object that quietly crosses threads.

The failure mode is predictable too: teams either disable checks, or carpet-bomb the codebase with @MainActor and @unchecked Sendable until the compiler stops shouting. Apple’s own guidance is to migrate incrementally by increasing strict concurrency checking first, then adopting Swift 6 language mode once you’ve resolved the issues. (developer.apple.com)

The migration shape that keeps teams sane

Two things are true at once:

  • Swift 6’s model is stricter and (when satisfied) gives you stronger guarantees.
  • Most real-world apps contain pre-concurrency patterns that were “fine” under GCD but are now explicitly unsafe.

Apple explicitly calls out the incremental route: turn on stricter checking via build settings before flipping the Swift 6 language mode switch. (developer.apple.com) Practically, that means you want a plan that produces small, reviewable diffs:

  1. Pick one module or feature area.
  2. Set Strict Concurrency Checking to Complete for that module/target first (not the whole workspace on day one).
  3. Fix warnings as if they were errors. (In Swift 6 mode, many of them will be.) (docs.swift.org)
  4. Only then move that module to Swift 6 language mode.

One more nuance that matters in 2026: newer SDKs have more accurate annotations (including main-actor assumptions for common UI-driven APIs), so you may be able to remove older, defensive @MainActor annotations after updating Xcode/SDKs. (developer.apple.com)

Refactor 1: Make UI-facing models explicitly @MainActor (and stop pretending they are reusable)

The repeating warning in SwiftUI migrations is some variant of “Main actor-isolated property cannot be accessed/mutated from a nonisolated context”.

The temptation is to keep view models “platform-agnostic” and avoid @MainActor. In a SwiftUI app, that’s usually a false economy: if a type is directly observed by SwiftUI and you mutate it from background tasks, you’ve built a footgun.

Pattern:

  • Mark UI state holders (ObservableObject / @Observable types used by views) as @MainActor.
  • Push background work behind an actor or Sendable service interface.
  • Have the view model do explicit actor hops at the boundary.

Example sketch:

  • View model: @MainActor, owns displayed state.
  • Domain service: actor (or a type whose methods are async and internally serialised).
  • Networking: Sendable structs + URLSession.

Trade-off:

  • You are admitting the view model is part of the UI layer.
  • That is correct. Trying to make it “pure” tends to create either data races or unreadable annotation soup.

Refactor 2: Replace “async work in init” with a two-phase start()

SwiftUI and strict concurrency collide badly around initialisation. You’ll see errors when initialisers are effectively nonisolated but touch main-actor-isolated properties, or when you pull in @Environment values / dependencies that are main-actor-bound.

There are also public discussions where people try to force SwiftUI View initialisers to be nonisolated to appease the compiler; Apple’s forums guidance is blunt: you generally should not need to mark custom View initialisers nonisolated, and doing so can fight SwiftUI’s intended isolation model. (developer.apple.com)

Pattern:

  • Keep init cheap and synchronous.
  • Introduce a start() (or load()) method on the view model.
  • Trigger start() from .task / .onAppear.

Checklist for this refactor:

  • init only stores dependencies and sets trivial defaults.
  • start() is @MainActor (because it mutates UI state) and spawns background tasks internally.
  • Cancellation is handled (store Task handles; cancel in deinit or onDisappear depending on lifecycle).

When the answer is “do not do this”:

  • Do not start long-running detached tasks in init and hope they outlive navigation changes. You will create stuck work and impossible-to-reason-about state races.

Refactor 3: Stop passing non-Sendable objects across actor boundaries; pass values or wrap the object

Swift 6 assumes concurrency by default: un-annotated code is treated as potentially concurrent, which forces you to be explicit about isolation and what can cross boundaries. (github.com) This is where many SwiftUI apps discover that “dependency injection” via class references everywhere is quietly unsafe.

Pattern:

  • Prefer value types for data transfer:
  • DTOs as struct
  • Immutable snapshots of state
  • If you must share mutable state, encapsulate it:
  • actor for mutable shared state
  • or @MainActor for UI-only state

Failure mode:

  • You “fix” the error with @unchecked Sendable on a class that is not actually thread-safe.

Use @unchecked Sendable only when all of the following are true:

  • You can justify thread-safety (internal locking, actor isolation, or true immutability).
  • You can test the concurrency behaviour.
  • You are prepared for it to become technical debt the next person will cargo-cult.

Refactor 4: Turn caches and singletons into actors (or make them main-actor only)

The classic migration surprise is a cache written “18 months earlier” that suddenly becomes the main blocker once you enable complete checking. (dzionis.by) Caches are shared mutable state; strict concurrency simply forces you to acknowledge that.

Pattern A (best default): actor Cache

  • All reads/writes are serialised by the actor.
  • The API becomes async (or you provide synchronous snapshots).

Pattern B (UI-only cache): @MainActor cache

  • Appropriate for image caches that are only ever used on the main thread via SwiftUI.
  • Dangerous if you later start prefetching or loading in background tasks.

Trade-off:

  • Actor-ising a cache can ripple async/await through call sites.

How to keep diffs small:

  1. Actor-ise the cache first with an API that matches existing usage.
  2. Add thin async wrappers at boundaries.
  3. Migrate call sites in one feature slice at a time.

Refactor 5: Fix delegate/protocol isolation mismatches with a “bridge” rather than annotations everywhere

A common Swift 6 diagnostic is that a main-actor isolated method cannot satisfy a nonisolated protocol requirement. Swift’s own diagnostics docs explicitly mention using @preconcurrency on conformances when the protocol itself didn’t describe the isolation requirement. (github.com)

Pattern:

  • Keep the protocol as-is (often you don’t own it).
  • Create a small bridge object that receives callbacks and hops to the right actor.

For UI callbacks:

  • Bridge calls into @MainActor view model via Task { @MainActor in ... }.

Why this is better than sprinkling @MainActor:

  • You localise the isolation decision.
  • You avoid making unrelated conforming types main-actor isolated by accident.

When the answer is “do not do this”:

  • Do not mark a widely-used protocol @MainActor just to fix one conformer unless you are confident every conformer is UI-only.

Refactor 6: Make navigation state a main-actor concern; keep deep-link parsing off the main actor

SwiftUI navigation tends to concentrate state in a handful of places (router objects, path arrays, selection state). Under strict concurrency, you’ll hit issues when:

  • A background task mutates navigation state.
  • A nonisolated coordinator calls into a main-actor router.

Pattern:

  • Keep the router/navigation model @MainActor.
  • Parse deep links, load remote config, and resolve IDs off-main.
  • Hop to main actor exactly once to apply navigation changes.

Example shape:

  • DeepLinkResolver (Sendable, pure functions) -> returns a value like Route.
  • NavigationStore (@MainActor) -> applies Route to path.

Trade-off:

  • You must be disciplined about “one hop, one mutation”. If you drip-feed mutations from background work, you’ll fight the compiler and your own UI consistency.

Refactor 7: Treat strict concurrency warnings as design feedback, not compiler noise

The most expensive migrations are the ones where teams chase warnings mechanically and end up with a codebase that compiles but is less legible than before.

Swift’s compiler diagnostics explain the core issue plainly: a nonisolated function can be called from any concurrency domain, so calling an actor-isolated method synchronously from it is an error. (docs.swift.org) If you respond by adding annotations until the error disappears, you may be encoding a lie.

Pattern:

  • For each warning, decide which of these is true:
  • The code is UI-only: isolate to @MainActor.
  • The code is shared mutable state: isolate to an actor.
  • The code is pure: make it Sendable/value-based and keep it nonisolated.
  • The boundary is wrong: add an explicit hop (Task { @MainActor in … } or await actor.method()).

Red flags that your migration is going off the rails:

  • You add @MainActor to types with no UI responsibilities “because it fixes errors”.
  • You add @unchecked Sendable without a clear thread-safety argument.
  • You introduce Task.detached to silence “cannot call async from sync” rather than redesigning the call chain.

How to keep the diff reviewable (the part teams usually miss)

Strict concurrency migrations fail socially before they fail technically: enormous diffs, reviewers can’t reason about correctness, and people start merging “because it compiles”.

A structure that works:

  • One PR per refactor pattern per feature area.
  • Each PR includes:
  • The compiler errors/warnings it addresses.
  • The chosen isolation model (main actor vs actor vs Sendable).
  • A note on what is intentionally not fixed yet (so reviewers don’t “help” by adding random annotations).

Order that minimises churn:

  1. Mark UI models @MainActor.
  2. Actor-ise caches/singletons.
  3. Convert protocol/delegate boundaries with bridges.
  4. Make value types Sendable where they actually cross boundaries.
  5. Only then touch deep architectural reshuffles.

This lines up with Apple’s incremental adoption approach: increase checking first, resolve issues, then move to Swift 6 language mode. (developer.apple.com)

Where codeversols fits (briefly, honestly)

If you’re shipping iOS at scale, strict concurrency is not a “nice-to-have” refactor; it changes how teams structure UI state, shared services, and callback boundaries. At codeversols we typically treat this as a guided engineering change: we pick a slice, apply the patterns above, and leave behind a codebase your team can extend without reintroducing the same warnings.

Close

Swift 6 strict concurrency is painful in SwiftUI apps for the same reason SwiftUI is productive: state is concentrated, and boundaries are implicit until the compiler forces you to make them explicit.

If you adopt a small set of repeatable refactors — @MainActor UI models, actor-backed shared state, bridge objects at protocol boundaries, and two-phase initialisation — you can migrate without disabling checks or turning your code review into archaeology.

More From Our Blog

Android 16 (API 36) edge-to-edge is enforced: the window insets contract your UI must satisfy
Mobile Engineering • Oct 16, 2026

Android 16 (API 36) edge-to-edge is enforced: the window insets contract your UI must satisfy

Targeting Android 16 (API 36) removes the practical opt-out from enforced edge-to-edge. The only stable fix is to define a single owner for each inset (status bars, navigation bars, display cutout, IME) and test for double-application in CI.

Read more →
TestFlight ‘Waiting for Review’ for days? Ship anyway with a dual‑lane release pipeline (and what to automate in App Store Connect)
Mobile Engineering • Oct 15, 2026

TestFlight ‘Waiting for Review’ for days? Ship anyway with a dual‑lane release pipeline (and what to automate in App Store Connect)

When TestFlight Beta App Review and App Store Review queues stall, you don’t need to miss your launch date. This playbook describes a dual‑lane pipeline (internal TestFlight vs external TestFlight vs App Store), deterministic rollout rules, and the App Store Connect automation to keep shipping under review latency.

Read more →
Swift 6 strict concurrency default isolation footguns: how teams accidentally @MainActor their whole app (and a CI check to prove it)
iOS / Swift • Oct 14, 2026

Swift 6 strict concurrency default isolation footguns: how teams accidentally @MainActor their whole app (and a CI check to prove it)

Swift 6’s strict concurrency checks get teams over the line, but one build setting can quietly turn large parts of your app into MainActor-only code. Here’s how that happens, how it shows up as UI jank, and a simple CI audit that catches “main-actor creep” early.

Read more →