KMP Flow to Swift 6 AsyncStream without data races
Meta description: Wire Kotlin Multiplatform Flows through expect/actual into Swift 6 strict concurrency without data races blocking App Store submissions.
TL;DR
Swift 6’s strict concurrency checking breaks most naive KMP interop layers. The fix is not a workaround — it’s a disciplined architectural boundary between Kotlin’s structured concurrency and Swift’s actor model. Get this right once and your shared code compiles cleanly on both sides of the fence.
The problem most teams hit first
error: Sending 'x' risks causing data races
That diagnostic is the first thing iOS engineers see when they wire up a KMP module under Swift 6 with -strict-concurrency=complete. The Kotlin side is clean. You expose a StateFlow through an expect/actual declaration, generate the Objective-C header, hand it off to iOS — and the compiler immediately blocks your App Store submission.
Most teams treat this as a Swift problem. It isn’t. It’s an architecture boundary problem, and the solution lives at the seam between the two runtimes.
How the two concurrency models conflict
Kotlin coroutines and Swift’s actor model share the same ambition — structured, safe concurrency — but enforce it through different mechanisms.
| Dimension | Kotlin Coroutines | Swift Actors (Swift 6) |
|---|---|---|
| Isolation unit | CoroutineScope + Dispatcher | actor / @MainActor |
| Async boundary | suspend / Flow | async/await / AsyncStream |
| Thread safety check | Runtime (with coroutine debug mode) | Compile-time (strict concurrency) |
| Interop layer | kotlinx.coroutines + ObjC bridge | @Sendable, @MainActor, isolation regions |
Swift 6 enforces isolation at compile time. When a Kotlin Flow crosses the ObjC bridge, Swift sees an unstructured callback from an unknown thread. That’s a data race by definition.
The expect/actual boundary is your friend
Treat expect/actual not as a thin alias, but as an isolation firewall.
Define your contract on the common side with no platform assumptions:
// commonMain
expect class FlowAdapter<T>(flow: Flow<T>) {
fun collect(onEach: (T) -> Unit, onComplete: () -> Unit)
fun cancel()
}
Then on iOS, the actual implementation must own the threading contract explicitly:
// iosMain
actual class FlowAdapter<T>(private val flow: Flow<T>) {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
actual fun collect(onEach: (T) -> Unit, onComplete: () -> Unit) {
scope.launch {
flow.collect { onEach(it) }
onComplete()
}
}
actual fun cancel() { scope.cancel() }
}
Dispatching on Dispatchers.Main before crossing the bridge means Swift receives callbacks on the main thread, which @MainActor can accept without a data race warning.
The @ObjCName pitfall
The @ObjCName annotation sounds harmless. It isn’t when generics are involved.
@ObjCName("FlowAdapterString")
actual class FlowAdapter<T> // ❌ Does not help — generics are erased at ObjC boundary
Swift 6 cannot verify Sendable conformance through erased generics. The practical fix is to specialize your adapters per type at the iosMain boundary — a concrete class per shared type (FlowAdapterString, FlowAdapterUser, and so on) rather than a single generic adapter.
Wiring to AsyncStream on the Swift side
The Swift snippet below uses FlowAdapterString — a concrete, specialized adapter — to illustrate the pattern after the specialization described above. This is what Swift 6 can actually verify:
// Swift 6 — FlowAdapterString is a specialized iosMain type, not a generic
@MainActor
func toAsyncStream(_ adapter: FlowAdapterString) -> AsyncStream<String> {
AsyncStream { continuation in
adapter.collect(
onEach: { continuation.yield($0) },
onComplete: { continuation.finish() }
)
}
}
This compiles without warnings under -strict-concurrency=complete because every access is bounded to @MainActor. Your ViewModel or ObservableObject consumes it inside a Task { @MainActor in ... } block and Swift’s isolation checker is satisfied.
One extra file per shared type is the right tradeoff. In my experience, the boilerplate cost is paid once per type and it eliminates an entire class of runtime crashes that are nearly impossible to reproduce in development. That’s a trade worth making.
What about background flows?
If you genuinely need non-main-thread collection — sensor data, heavy decoding — dispatch on Dispatchers.Default in iosMain and own the isolation domain on the Swift side with a detached actor:
// Swift 6 — background actor owns isolation
actor SensorProcessor {
nonisolated func attach(_ adapter: FlowAdapterSensorReading) -> AsyncStream<SensorReading> {
AsyncStream { continuation in
adapter.collect(
onEach: { continuation.yield($0) },
onComplete: { continuation.finish() }
)
}
}
func process() async {
for await reading in attach(SensorKt.sensorAdapter()) {
// isolated to SensorProcessor — no data race
handleReading(reading)
}
}
}
The nonisolated boundary on attach lets the AsyncStream be constructed without actor-hopping, while process() — isolated to SensorProcessor — consumes it safely. Never let the bridge decide threading for you; by the time the callback arrives in Swift, the compiler has no way to verify where it came from.
Three things to get right
Own the dispatcher in iosMain. Always resolve to Dispatchers.Main (or Dispatchers.Default for background actors) before any callback crosses the ObjC bridge. Don’t leave threading to the Swift call site — by then it’s too late for the compiler to verify.
Specialize before exposing generics. Generic expect/actual declarations with @ObjCName don’t solve Sendable erasure. Specialize per concrete type at the iosMain boundary. One extra file per shared type is the correct tradeoff.
Wrap in AsyncStream immediately on the Swift side. The moment a specialized adapter callback enters Swift, convert it to AsyncStream inside a @MainActor or actor-isolated context. This gives Swift 6’s strict concurrency checker a clear, verifiable isolation boundary — and keeps your App Store submission on schedule.
Tags: kotlin kmp multiplatform swift ios