MVP Factory
ai startup development

KMP Flow to Swift 6 AsyncStream without data races

KW
Krystian Wiewiór · · 5 min read

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.

DimensionKotlin CoroutinesSwift Actors (Swift 6)
Isolation unitCoroutineScope + Dispatcheractor / @MainActor
Async boundarysuspend / Flowasync/await / AsyncStream
Thread safety checkRuntime (with coroutine debug mode)Compile-time (strict concurrency)
Interop layerkotlinx.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


Share: Twitter LinkedIn