DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

KMP's expect/actual Meets Swift 6 Strict Concurrency: Bridging Kotlin Coroutines to Swift's Actor Model Without Data Races

---
title: "KMP Flow to Swift 6 AsyncStream Without Data Races"
published: true
description: "Wire Kotlin Multiplatform Flows through expect/actual into Swift 6 strict concurrency without data races blocking App Store submissions."
tags: kotlin, swift, ios, mobile
canonical_url: https://blog.mvpfactory.co/kmp-flow-swift6-asyncstream-data-races
---
Enter fullscreen mode Exit fullscreen mode

What You Will Build

By the end of this tutorial, you will have a working expect/actual boundary that exposes Kotlin Flow to Swift 6 as AsyncStream — compiling cleanly under -strict-concurrency=complete with zero data race warnings. No workarounds, no suppression pragmas. A real architectural fix you can ship to the App Store.


Prerequisites

  • A KMP project targeting iOS with kotlinx.coroutines
  • Xcode 16+ with Swift 6 strict concurrency enabled
  • Basic familiarity with StateFlow and Swift's async/await

The Problem

Here is the error your iOS team will see first:

error: Sending 'x' risks causing data races
Enter fullscreen mode Exit fullscreen mode

Swift 6 with -strict-concurrency=complete blocks compilation the moment a Kotlin Flow crosses the ObjC bridge. Swift sees an unstructured callback arriving from an unknown thread. That is a data race by definition, and the compiler will not let it pass.

Most teams treat this as a Swift problem. It is not. It is an architecture boundary problem, and the fix lives at the seam between the two runtimes.

Dimension Kotlin Coroutines Swift Actors (Swift 6)
Isolation unit CoroutineScope + Dispatcher actor / @MainActor
Async boundary suspend / Flow async/await / AsyncStream
Thread safety check Runtime Compile-time

Step 1 — Define the Contract in commonMain

Let me show you a pattern I use in every project. Treat expect/actual not as a thin alias, but as an isolation firewall. Define the contract with no platform assumptions:

// commonMain
expect class FlowAdapter<T>(flow: Flow<T>) {
    fun collect(onEach: (T) -> Unit, onComplete: () -> Unit)
    fun cancel()
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Own the Dispatcher in iosMain

The actual implementation must own the threading contract explicitly. This is the key move:

// 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() }
}
Enter fullscreen mode Exit fullscreen mode

Dispatching on Dispatchers.Main before crossing the bridge means Swift receives callbacks on the main thread — which @MainActor accepts without a data race warning. Never leave threading to the Swift call site; by then it is too late for the compiler to verify.


Step 3 — Specialize Per Type, Then Wrap in AsyncStream

The docs do not mention this clearly, but generic expect/actual declarations with @ObjCName do not solve Sendable erasure. Swift 6 cannot verify Sendable conformance through erased generics at the ObjC boundary.

The fix is to specialize your adapters per concrete type at the iosMain boundary — FlowAdapterString, FlowAdapterUser, and so on. One extra file per shared type.

Once specialized, wire to AsyncStream immediately:

// Swift 6
@MainActor
func toAsyncStream(_ adapter: FlowAdapterString) -> AsyncStream<String> {
    AsyncStream { continuation in
        adapter.collect(
            onEach: { continuation.yield($0) },
            onComplete: { continuation.finish() }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

This compiles clean under -strict-concurrency=complete because every access is bounded to @MainActor. Consume it in your ViewModel inside a Task { @MainActor in ... } block and the isolation checker is satisfied.


Bonus — Background Flows With a Detached Actor

If you need non-main-thread collection — sensor data, heavy decoding — dispatch on Dispatchers.Default in iosMain and own the isolation domain on the Swift side:

// 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()) {
            handleReading(reading)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The nonisolated boundary on attach lets the AsyncStream be constructed without actor-hopping, while process() — isolated to SensorProcessor — consumes it safely.


Gotchas

@ObjCName with generics does nothing useful. It does not resolve Sendable erasure. Specialize before you expose — always.

Do not let the bridge decide threading. If you skip Dispatchers.Main in iosMain, callbacks arrive on Kotlin's thread pool. Swift 6 has no way to verify that at compile time, and you will get data race errors that are nearly impossible to reproduce in development.

Wrap in AsyncStream at the earliest possible moment. The longer a raw callback lives unstructured in Swift, the harder it becomes for the compiler to verify isolation. Convert immediately on entry.


Conclusion

The boilerplate cost of one specialized adapter file per shared type is paid once. In return, you eliminate an entire class of runtime crashes that are nearly impossible to reproduce locally — and you keep your App Store submissions on schedule. That is a trade worth making every time.

Resources:

Top comments (0)