Swift 6 + Kotlin coroutines: fixing KMP data races
Meta description: Patterns to bridge Swift 6 strict concurrency and Kotlin coroutines in KMP: checked continuations, AsyncStream adapters, and actor isolation.
Tags: kotlin, swift, kmp, multiplatform, architecture
TL;DR
Swift 6’s strict concurrency checking turns every KMP-exported Kotlin API into a potential compiler error. Kotlin’s suspend functions become completion handlers that aren’t @Sendable, Flow types lack AsyncSequence conformance, and shared data classes fail Sendable checks. Three wrapper patterns fix this: checked continuations, AsyncStream adapters, and actor-isolated repositories. They eliminate the warnings while preserving structured concurrency on both sides.
The collision no one prepared for
Kotlin Multiplatform promised shared business logic. Swift 6 promised data race safety at compile time. What nobody mentioned is that these two guarantees clash at the platform boundary.
When you export a Kotlin suspend fun getUser(id: String): User to Swift, SKIE or the Kotlin/Native compiler generates an Objective-C method with a completion handler. Swift 6 immediately flags this: the closure isn’t marked @Sendable, and the returned User type doesn’t conform to Sendable. You’re stuck before you’ve written a single line of Swift.
This matters because concurrency bugs multiply. Every data race you eliminate at the boundary prevents a cascade of issues downstream. Get this layer right once, and every module you add inherits the safety guarantees automatically.
Where the friction actually lives
Here’s what breaks, mapped to severity:
| KMP export | Swift 6 problem | Severity | Fix pattern |
|---|---|---|---|
suspend fun | Completion handler not @Sendable | Build error | Checked continuation wrapper |
Flow<T> | No AsyncSequence conformance | Build error | AsyncStream adapter |
data class | Not Sendable | Warning → error | @unchecked Sendable or actor isolation |
| Shared mutable state | Global actor isolation mismatch | Build error | Actor-isolated repository |
| Callback-based APIs | @Sendable closure requirements | Build error | withCheckedContinuation |
In my experience with production KMP apps, the majority of Swift 6 migration effort lands in this interop layer, not in pure Swift code. On the last project I shipped, it accounted for the bulk of concurrency-related fixes.
Pattern 1: Checked continuations for suspend functions
Wrap every exported suspend function in a Swift actor that bridges through withCheckedThrowingContinuation:
actor UserRepository {
private let sdk: SharedUserSDK
func getUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
sdk.getUser(id: id) { user, error in
if let error {
continuation.resume(throwing: error)
} else if let user {
continuation.resume(returning: user)
} else {
continuation.resume(throwing: KMPBridgeError.unexpectedNilResult)
}
}
}
}
}
enum KMPBridgeError: Error {
case unexpectedNilResult
}
The actor boundary gives you Sendable isolation automatically. The checked continuation bridges the callback into structured concurrency. That else clause handles the edge case where Objective-C completion handlers deliver nil for both value and error. Without it, the continuation leaks silently. No force-casts, no @unchecked escape hatches.
Pattern 2: AsyncStream adapters for flows
Kotlin Flow exports are the worst offender. The generated Objective-C interface gives you a collector callback with no AsyncSequence conformance. The fix:
extension UserRepository {
func observeUsers() -> AsyncStream<[User]> {
AsyncStream { continuation in
let job = sdk.observeUsers().collect { users in
continuation.yield(users)
}
continuation.onTermination = { _ in job.cancel() }
}
}
}
This gives your SwiftUI views a clean for await interface while respecting cancellation on both sides. When I switched from raw callback forwarding to this pattern, concurrency-related crashes dropped noticeably. Cancellation and lifecycle get handled in one place instead of scattered across call sites.
Pattern 3: Actor-isolated repositories
The mistake I keep seeing: teams sprinkle @MainActor on individual view models instead of isolating at the repository layer. That creates a web of implicit main-thread dependencies.
Instead, create a single actor that owns all SDK access:
@globalActor actor SharedSDKActor {
static let shared = SharedSDKActor()
}
@SharedSDKActor
final class KMPRepository {
private let sdk: SharedSDK
func getUser(id: String) async throws -> User { /* ... */ }
func observeUsers() -> AsyncStream<[User]> { /* ... */ }
}
View models annotated with @MainActor call into KMPRepository across actor boundaries. Swift 6’s compiler enforces the handoff. No data races, no runtime surprises.
Why this pays off
This layered approach takes real upfront investment. But the payoff compounds. Once the interop layer is solid, every new KMP module slots in cleanly. Every new feature gets concurrency safety without extra work.
I won’t pretend it’s painless though. You’ll spend a few frustrating days getting the first module through the compiler. The error messages for actor isolation violations are… not great. But once you internalize the patterns, subsequent modules go fast.
Takeaways
-
Wrap at the boundary, not the call site. Build a single actor-isolated repository layer that adapts all KMP exports to Swift’s structured concurrency. Don’t scatter
withCheckedContinuationacross view models. -
Prefer
AsyncStreamover raw callbacks for flows. It preserves cancellation semantics, composes withfor await, and satisfiesSendablerequirements without escape hatches. -
Invest in the interop layer early. Race conditions multiply as your shared module surface grows, and retrofitting this stuff is miserable. Treat it as infrastructure, not boilerplate.