---
title: "Swift 6 + Kotlin Coroutines: Fixing KMP Data Races with Three Wrapper Patterns"
published: true
description: "Patterns to bridge Swift 6 strict concurrency and Kotlin coroutines in KMP: checked continuations, AsyncStream adapters, and actor isolation."
tags: kotlin, swift, mobile, architecture
canonical_url: https://blog.mvpfactory.co/swift-6-kotlin-coroutines-fixing-kmp-data-races
---
## What We're Building
Let me show you a pattern I use in every KMP project now. We'll build a concurrency-safe interop layer that bridges Kotlin `suspend` functions and `Flow` types into Swift 6's strict concurrency model — without a single `@unchecked Sendable` escape hatch.
By the end of this tutorial, you'll have three reusable wrapper patterns: checked continuations for suspend functions, `AsyncStream` adapters for flows, and an actor-isolated repository that ties everything together.
## Prerequisites
- A Kotlin Multiplatform project exporting a shared module to iOS
- Swift 6 strict concurrency checking enabled
- Familiarity with Kotlin coroutines (`suspend`, `Flow`) and Swift's `async/await`
## The Problem, in One Table
When you export Kotlin APIs to Swift, the generated Objective-C bridge clashes with Swift 6's compile-time data race safety. Here's the damage:
| 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 production KMP apps, the majority of Swift 6 migration effort lands in this interop layer, not in pure Swift code. Here's the minimal setup to get this working.
## Step 1: Checked Continuations for Suspend Functions
Wrap every exported `suspend` function in a Swift actor that bridges through `withCheckedThrowingContinuation`:
swift
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. 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 escape hatches.
## Step 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:
swift
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. Switching from raw callback forwarding to this pattern noticeably reduces concurrency-related crashes — cancellation and lifecycle get handled in one place.
## Step 3: Actor-Isolated Repository
The docs don't mention this, but sprinkling `@MainActor` on individual view models instead of isolating at the repository layer creates a web of implicit main-thread dependencies. Instead, create a single actor that owns all SDK access:
swift
@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.
## Gotchas
**The nil/nil completion handler.** Objective-C bridges can call your completion handler with `nil` for both the result and the error. If you don't handle this, `withCheckedContinuation` will never resume — and you'll get a silent hang, not a crash. Always add the `else` clause.
**Don't scatter `withCheckedContinuation` across view models.** Wrap at the boundary once. If every call site does its own bridging, you're multiplying the surface area for mistakes.
**Actor isolation error messages are rough.** You'll spend frustrating time deciphering them during your first module. Push through — subsequent modules go fast once you internalize the patterns.
**Cancellation propagation.** The `onTermination` handler on `AsyncStream` is your only hook to cancel the underlying Kotlin `Job`. Miss it and you've got a leaked coroutine collecting data nobody reads. This is the kind of subtle bug that only shows up in long sessions — I actually caught one during an extended coding block when [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) reminded me to take a break, and I came back with fresh eyes on the memory graph.
## Conclusion
This layered approach takes real upfront investment, but the payoff compounds. Once the interop layer is solid, every new KMP module slots in cleanly and inherits concurrency safety without extra work.
Three rules to take with you:
1. **Wrap at the boundary, not the call site.** Build a single actor-isolated repository layer.
2. **Prefer `AsyncStream` over raw callbacks** for flows — it preserves cancellation and satisfies `Sendable`.
3. **Invest in the interop layer early.** Race conditions multiply as your shared module surface grows, and retrofitting is miserable.
Treat this layer as infrastructure, not boilerplate. Your future self will thank you.
Top comments (0)