Swift 6 Strict Concurrency and Kotlin Multiplatform: Two Ways to Not Have a Data Race
Swift 6.2 inverted its concurrency defaults — main actor by default, escape on purpose — and it changed migration from a slog into something tractable. Meanwhile KMP shares the logic that would otherwise be written twice. Here is how the two fit together.
Two of the most consequential shifts in native mobile engineering are happening at once, and they are usually discussed separately: Swift making data races a compile-time error, and Kotlin Multiplatform making "write it twice" optional.
They interact more than people expect, because both are ultimately about the same thing — where the boundaries in your app are, and what crosses them.
Swift 6, and why the first attempt was so painful
Swift 6 language mode made data-race safety a compile-time guarantee. Every mutable value crossing an isolation boundary must be provably safe, or the code does not build.
The goal was never controversial. The execution, in Swift 6.0, was brutal. Turning on strict concurrency in a real app produced hundreds of errors, and the errors were about things that had worked correctly for years. The reason was a default that turned out to be backwards: nonisolated functions were free-floating, so calling one from the main actor hopped to a concurrent executor — which meant everything crossing that call needed to be Sendable, which meant annotating types that had never left the main thread in their lives.
Teams either annotated their way through it or turned the flag off. Most turned it off.
Swift 6.2 turned the defaults around
Swift 6.2's Approachable Concurrency is the correction, and it is a genuine one. Three changes:
Default actor isolation
You can set a module to be @MainActor by default. Every type and function in it is main-actor isolated unless you say otherwise.
This matches what UI code actually is. A view model, a coordinator, a formatter, a router — these live on the main thread and always did. Declaring that once, at module level, removes the annotation from every file.
// Build setting: SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
// No @MainActor annotation needed — it is the default now.
final class ProfileViewModel {
private(set) var profile: Profile?
private(set) var isLoading = false
func load(id: Profile.ID) async throws {
isLoading = true
defer { isLoading = false }
profile = try await api.profile(id: id)
}
}nonisolated(nonsending) by default
This is the subtle one, and it is where most of the pain went.
Under approachable concurrency, a nonisolated async function inherits the caller's isolation rather than hopping to a concurrent executor. Called from the main actor, it runs on the main actor.
Which means the arguments never cross an isolation boundary, which means they do not need to be Sendable, which means the cascade of annotations that made 6.0 miserable simply does not start.
The mental model is worth stating clearly: nonisolated now means "I do not have my own isolation", not "I run somewhere else".
@concurrent to opt out on purpose
When you genuinely want work off the caller's actor, you say so:
@concurrent
func decodeThumbnails(_ payloads: [Data]) async throws -> [Image] {
try await withThrowingTaskGroup(of: Image.self) { group in
for payload in payloads {
group.addTask { try decode(payload) }
}
return try await group.reduce(into: []) { $0.append($1) }
}
}Now the compiler asks for Sendable — because now you are actually crossing a boundary, and the check is doing real work rather than taxing you for a hop you never wanted.
The inversion is the whole design: concurrency is opt-in at the point where it exists, rather than opt-out everywhere it does not.
Migrating, in order
- Turn on approachable concurrency and default main-actor isolation before anything else. This alone removes the large majority of diagnostics in a UI-heavy target.
- Fix what remains, from the leaves inward. Errors in low-level types cascade upward; errors in views usually do not cascade at all.
- Mark genuinely parallel work
@concurrent. Image decode, parsing, crypto, disk. Usually a handful of functions in a whole app. - Use
sendingfor ownership transfer where a value moves across a boundary and the sender will not touch it again. It is more precise than making the typeSendableand does not constrain every other use of that type. - Treat
@unchecked Sendableas debt with a comment. It is sometimes correct — a type guarded by its own lock — but it is you asserting what the compiler was there to prove.
Kotlin Multiplatform, and what it is actually for
KMP shares Kotlin code across Android, iOS, desktop and web while keeping the UI native. It has been production-stable since late 2023, Google backs it officially, and it runs in front of very large user bases at companies including Cash App, Netflix, McDonald's and Philips. The "is it ready?" question is settled.
The better question is what to share.
| Share this | Keep it native |
|---|---|
| Networking, serialisation, API clients | Views, navigation, animation |
| Domain models and validation | Platform capabilities: camera, biometrics, widgets |
| Business rules and state machines | Anything tied to a platform release cycle |
| Persistence and sync logic | Anything where platform feel is the product |
| Analytics schemas | Accessibility semantics |
Most successful KMP adoptions share 30–50% and stop. Teams chasing 90% usually end up fighting the platform at the exact places users notice.
Compose Multiplatform is the other half of the story and has come a long way: stable for Android, iOS and desktop, with real iOS scroll physics, native text selection and accessibility wiring. It is a legitimate choice for internal tools, for content-heavy apps, and for teams with no iOS specialist. For a consumer app where feel is the differentiator, SwiftUI on top of shared Kotlin remains the safer bet.
Where the two meet: the interop seam
Historically the Kotlin↔Swift boundary went through Objective-C, and that seam leaked. suspend functions became completion handlers. Sealed classes became a class hierarchy you had to is-check by hand. Nullability was approximate. Generics mostly vanished.
Swift export is the replacement: Kotlin modules exposed as real Swift modules, with suspend functions mapping to Swift async/await and sealed classes mapping to Swift enums with associated values. Kotlin 2.4 added Swift packages as dependencies and further Swift export work. It is still experimental, with stable interop targeted for 2026.
Two practical consequences today:
Do not expose a wide Kotlin surface to Swift. Every exported type is a type you will have to re-bridge when the export model changes. Define a narrow, deliberate facade — a dozen functions and a handful of data classes — and keep the rest internal. That is good architecture regardless, and here it also limits your migration cost.
Bridge concurrency at the boundary, once. Do not scatter Kotlin coroutine adapters through your Swift code. Write one adapter layer that turns Kotlin's async surface into Swift async functions, and have the rest of the app talk to that. When Swift export stabilises, you rewrite one file.
That layer is also where Swift 6 isolation meets Kotlin threading, and it deserves a deliberate rule: the bridge returns main-actor-isolated values. Shared Kotlin code has its own dispatchers; Swift has its own isolation model; the seam is where you decide which one wins, and "everything comes back on the main actor unless explicitly marked otherwise" is the rule that produces the fewest surprises.
The thing both are really teaching
Swift 6 makes you name every boundary where data moves between isolation domains. KMP makes you name every boundary between shared logic and platform code. Both are the same discipline: be explicit about where your program's seams are, and what is allowed to cross them.
That is why teams that already had clean module boundaries found the Swift 6 migration tedious but bounded, and teams that did not found it a rewrite. The compiler did not create the coupling. It just stopped letting it stay invisible.
Sources: Approachable Concurrency in Swift 6.2, What is @concurrent in Swift 6.2, Kotlin Multiplatform FAQ, Compose Multiplatform 1.11