Swift 6 strict concurrency is doing what it promised: forcing you to be explicit about who owns mutable state and where it is allowed to be touched. The problem is that one “fix” (often slapping @MainActor on a type) can trigger a chain reaction of async/await and isolation constraints that spreads through every layer.
That “concurrency contagion” is no longer theoretical. It showed up as a direct, widely-voted Swift Group Lab question at WWDC26: teams asked how to stop @MainActor from causing “a massive chain reaction of async refactoring across a legacy codebase” while still keeping Swift 6 safety. (developer.apple.com)
What changed: strict checking turns implicit assumptions into compile-time contracts
In Swift 6 language mode, strict concurrency checking is on by default, and Xcode supports configuring the level of strictness during migration. Apple’s guidance is explicit: strict concurrency checking is about detecting data races at compile time, and the migration path includes turning on “Complete” checking before switching targets to Swift 6 mode. (developer.apple.com)
The relevant mechanics (the ones that make “contagion” feel inevitable) are:
- Actor isolation becomes part of your API surface. If a method/property is isolated to
@MainActor(or any actor), callers must respect that isolation. - `Sendable` checking becomes non-negotiable at task/actor boundaries. A value that crosses isolation domains must be
Sendable(or otherwise handled explicitly), and the compiler pushes you towards designs where this is provably safe. (github.com) - Global variables get stricter treatment. Under strict concurrency rules, globals must be isolated or both immutable and
Sendable; “papering over” shared globals becomes harder and forces architectural decisions. (github.com)
This is why “just make it @MainActor” is dangerous: it often doesn’t fix the underlying design. It merely chooses one isolation domain (the main actor) and then forces everything that touches that state to either run on the main actor or cross the boundary correctly.
The failure mode: you ‘fix’ warnings by centralising state on the main actor
Here’s the pattern I keep seeing in migrations:
- A view model or coordinator gets annotated
@MainActorto silence warnings. - Business logic depends on that type.
- Repository/service code depends on the business logic.
- Suddenly large parts of the app either: - become implicitly main-actor bound (and your background work isn’t really background work), or - require
awaitat call sites that were never designed to suspend.
The visible symptoms:
- Your “sync” interfaces quietly become
async, changing control flow and error handling. - Performance regressions appear because previously-off-main work now runs on the main actor (or frequently hops to it).
- Teams start reaching for
@unchecked Sendable,nonisolated(unsafe), or broad@MainActorannotations to get the build green, which can reintroduce the very races strict concurrency was meant to eliminate.
If you’re leading a team, the key point is organisational: this isn’t a compiler nag you can ignore. It is a forcing function for architecture.
Boundary pattern 1: a facade that is async at the edges, sync inside
The simplest way to stop strict concurrency from leaking everywhere is to decide where your async boundaries are allowed to live.
A practical rule:
- UI-facing code may be
@MainActor. - Truly concurrent work may be in actors.
- Everything else should be plain Swift types with synchronous APIs where possible.
To make that work, insert a facade at the boundary.
How it works
- The facade is responsible for:
- hopping to/from
@MainActoras needed, - calling into actors with
await, - converting types into
SendableDTOs (more on those below). - Your domain layer stays synchronous and testable, and doesn’t inherit “actor-ness” by accident.
When not to do this
Do not use a facade to pretend that long-running work is synchronous. If a domain operation genuinely needs to suspend (network, disk, long compute), forcing a sync API will push you into blocking, semaphores, or other “make it look sync” tricks. That’s usually worse than accepting async where it is real.
The intent is containment, not denial.
Boundary pattern 2: actor ‘islands’ with narrow, deliberately Sendable interfaces
Most codebases do not need “actors everywhere”. What they need is a few well-chosen actor islands that own specific mutable state.
This lines up with the core model described in Swift’s concurrency documentation: values are safe to cross isolation domains when they are Sendable, and the main actor is a special global actor used for UI state. (docs.swift.org)
What an actor island looks like
- One actor owns one category of shared mutable state:
- cache + eviction policy
- token refresh
- sensor pipeline aggregation
- persistence write coordination
- The actor exposes methods that accept and return Sendable types only.
The important design constraint is this: if you let non-Sendable framework types leak into the actor’s public surface, you will reintroduce contagion (callers now have to deal with isolation and Sendable mismatches).
Trade-off
- Pro: the compiler becomes your ally. Races get prevented structurally.
- Con: every call into the actor is an
awaitpoint. Overuse can make code noisy and can change performance characteristics.
A rule of thumb: if a type does not protect shared mutable state, it probably should not be an actor.
Boundary pattern 3: Sendable DTOs (snapshots) to cross isolation domains
If you want to stop Sendable constraints spreading, you need a deliberate strategy for data crossing boundaries.
The strategy that consistently works is: convert to a Sendable snapshot at the boundary.
- Your internal model types can remain rich, reference-based, framework-backed, or otherwise not Sendable.
- At the boundary (UI ↔ service, actor ↔ non-actor, background ↔ main), you map into:
- immutable structs,
- containing only Sendable fields,
- designed as “just data”.
This is exactly the mental model Swift’s Sendable checking pushes you towards: values passed across tasks/actors should be safe to share without races. (docs.swift.org)
Failure mode to watch
The common mistake is to create DTOs that still smuggle shared mutable references (e.g. a class reference, a non-thread-safe collection, or a framework object) inside.
If you find yourself reaching for @unchecked Sendable on a DTO, treat that as a design review trigger. Sometimes it’s justified (e.g. internally-synchronised types), but in many migrations it’s used as a shortcut that defeats the point.
Boundary pattern 4: minimise cross-actor chatter (coalesce, batch, drop)
WWDC26 also featured a very practical question: how to ingest high-frequency sensor data off the main actor, then update a MainActor @Observable model without blocking UI. Apple’s answer was essentially: don’t try to forward every sample; cross the boundary as infrequently as the UI needs by coalescing/accumulating, and decide what data loss is acceptable. (developer.apple.com)
That advice generalises far beyond sensors:
- If you have a background pipeline producing events faster than the UI can render, you must introduce a buffer/aggregation stage off-main.
- Your boundary should be “UI frames” or “meaningful state changes”, not “every low-level event”.
This is not just performance work; it is concurrency architecture. You are reducing the number of actor hops, which reduces your surface area for isolation problems.
Boundary pattern 5: transfer ownership deliberately (region-based isolation and ‘sending’)
One of the hardest real-world issues is passing large, non-Sendable data between isolation domains without copying.
At the WWDC26 Swift Group Lab, the panel pointed to region-based isolation and the sending keyword concept: you can transfer non-Sendable data between actors when the compiler can prove the original actor no longer accesses it after the handoff, and sending can make that transfer explicit when needed. (developer.apple.com)
This matters because “copy it into a Sendable struct” is not always viable for performance or memory.
Do not do this
Do not use ownership transfer to launder shared mutable state.
If both sides keep touching the same object after “transfer”, you have not solved concurrency; you have just made it harder for the compiler (and reviewers) to reason about. Ownership transfer is a tool for moving responsibility, not sharing it.
A migration playbook that avoids painting everything @MainActor
If you’re deciding whether your team can do this without stalling delivery, the cleanest approach I’ve seen is staged:
- Turn on stricter checking before Swift 6 mode for key targets, so you surface issues incrementally rather than in one catastrophic PR. Apple explicitly supports stepping up strictness as part of the migration path. (developer.apple.com)
- Choose your boundaries (UI boundary, persistence boundary, networking boundary). Write them down.
- Create DTOs at boundaries and ban non-Sendable types crossing them.
- Introduce actor islands only where they own mutable shared state.
- Add facades so async/await is concentrated at the edges, not smeared through the domain.
- Audit any `@unchecked Sendable`: every instance should have a written justification and a test strategy.
This is the difference between “the compiler made our code async” and “we designed the async surfaces we actually want”.
Where codeversols fits (briefly, and honestly)
At codeversols we tend to get involved when teams are mid-migration and have hit exactly this wall: builds are failing under complete checking, the quickest fixes are making performance worse, and the team needs an architecture that contains isolation rather than spreading it. If you want a second pair of eyes on your boundary design (not just a pile of annotations), that’s the kind of engagement we’re useful for.
Close
Swift 6 strict concurrency isn’t a style preference; it is an API design constraint that forces you to state where mutable state lives and how it moves. The teams getting through migrations in 2026 aren’t winning by adding more @MainActor—they’re winning by defining boundaries: facades to concentrate async, actor islands that own state, and Sendable DTOs (or explicit ownership transfer) to cross domains safely.
If your migration plan doesn’t include boundary design, you’re not migrating concurrency; you’re just moving compiler errors around.



