Bridging Kotlin Coroutines and Swift 6 Structured Concurrency in KMP: Building Leak-Free Shared Async APIs
TL;DR
Exposing Kotlin coroutines to Swift 6 structured concurrency is the hardest integration point in production KMP. I’m not being dramatic. Raw suspend functions become callback-based completion handlers, Flows become unwieldy wrappers, and Swift 6’s strict Sendable checking rejects most naive approaches outright. The fix is a layered strategy: use SKIE to generate idiomatic Swift async/AsyncSequence bindings, define clear expect/actual concurrency boundaries, and scope every coroutine to a cancellable lifetime tied to the platform. Here’s the architecture that has kept our shared modules leak-free across three production apps.
The problem: two concurrency worlds, one shared module
Kotlin/Native’s coroutine-to-Objective-C bridge produces signatures like this on the Swift side:
// What Kotlin `suspend fun getUser(id: String): User` becomes:
func getUser(id: String, completionHandler: @escaping (User?, Error?) -> Void)
Meanwhile, Swift 6 enforces strict data isolation. That User type? If it’s not Sendable, the compiler rejects it at actor boundaries. Multiply this across a real API surface (repositories, use cases, reactive streams) and you’re fighting the toolchain instead of shipping features.
| Challenge | Kotlin side | Swift 6 side |
|---|---|---|
| Async functions | suspend fun | Needs async / await |
| Reactive streams | Flow<T> | Needs AsyncSequence |
| Thread safety | Coroutine dispatchers | Actor isolation + Sendable |
| Cancellation | CoroutineScope.cancel() | Task cancellation (cooperative) |
| Object freezing (legacy) | @SharedImmutable / new MM | @unchecked Sendable workarounds |
Layer 1: SKIE for idiomatic bridging
Touchlab’s SKIE transforms the generated Objective-C headers so Swift sees native async signatures. This single tool eliminated roughly 70% of our bridging boilerplate by line count.
// Shared module — Kotlin
class UserRepository(private val api: UserApi) {
suspend fun getUser(id: String): User = api.fetchUser(id)
fun observeUsers(): Flow<List<User>> = api.usersFlow()
}
With SKIE enabled, Swift sees:
// Swift — generated by SKIE
let user = try await repository.getUser(id: "123")
for await users in repository.observeUsers() {
updateUI(users)
}
No manual wrapping. No Kotlinx_coroutines_coreFlowCollector conformance. On a project with 42 shared use cases, SKIE cut our Swift-side glue code from about 1,800 lines to under 200. Your numbers will differ depending on API surface complexity, but I’ve seen similar ratios on every project where I’ve introduced it.
Layer 2: Disciplined coroutine scoping
Most teams get this wrong: they create a CoroutineScope inside the shared module with no platform-aware cancellation. This leaks coroutines when a SwiftUI view disappears or an Android ViewModel clears. I’ve debugged this exact issue more times than I’d like to admit.
The pattern I use (simplified for clarity; see the official Kotlin docs on expected and actual declarations for full syntax rules):
// Shared — expect/actual for lifecycle-bound scope
expect class PlatformScope() {
val scope: CoroutineScope
fun cancel()
}
// Android actual
actual class PlatformScope {
actual val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
actual fun cancel() { scope.cancel() }
}
// iOS actual
actual class PlatformScope {
private val job = SupervisorJob()
actual val scope = CoroutineScope(job + Dispatchers.Main)
actual fun cancel() { job.cancel() }
}
On iOS, tie cancel() to the deinit of a coordinator or the .task modifier’s cancellation. On Android, tie it to onCleared(). Every coroutine launched in shared code flows through this scope. No orphans, no leaks.
Layer 3: Conquering Swift 6 Sendable
Swift 6’s strict concurrency checking means any type crossing an isolation boundary must conform to Sendable. Kotlin-generated classes don’t conform by default.
Two strategies that work in production:
- Mark Kotlin-generated types as
@unchecked Sendableon the Swift side. If your Kotlin data classes are effectively immutable (allvalproperties, no mutable internal state), you can safely declare conformance:
// Kotlin — shared module
data class User(val id: String, val name: String, val email: String)
// Swift — in a project-level extensions file
extension User: @unchecked Sendable {}
This is a project-level convention, not automatic. You’re asserting to the Swift compiler that User is safe to pass across isolation boundaries. Audit each type. If a Kotlin class holds mutable state or references mutable collections, this annotation will hide real concurrency bugs.
- For mutable shared state, use the actor pattern on Swift’s side. Keep the Kotlin shared module as a plain class, then wrap it in a Swift actor:
actor UserStore {
private let repository: UserRepository
func loadUser(id: String) async throws -> User {
try await repository.getUser(id: id)
}
}
This satisfies the compiler’s isolation requirements without polluting your shared Kotlin code with platform-specific annotations.
Common traps I’ve hit in production
| Trap | Symptom | Fix |
|---|---|---|
Unscoped GlobalScope.launch in shared code | Memory leak on iOS, coroutine runs after view dies | Always use PlatformScope |
| Non-Sendable types across actor boundaries | Swift 6 compiler error | @unchecked Sendable for immutable types or actor wrapping |
Flow.collect without cancellation | Collector never terminates on iOS | Use SKIE’s AsyncSequence bridging + Task cancellation |
Dispatchers.Main on background Kotlin/Native thread | Crash: IncorrectDereferenceException | Ensure initial call originates from main thread or use Dispatchers.Main.immediate |
| Ignoring back-pressure on high-frequency Flows | UI jank and dropped frames | Use conflate() or debounce() before exposing to Swift |
What to do with all this
Adopt SKIE. Seriously, just do it today. It turns Kotlin suspend functions and Flow into Swift-native async and AsyncSequence with zero manual bridging. The reduction in glue code alone justifies the dependency.
Scope every coroutine to platform lifecycle using expect/actual to create cancellable scopes tied to Android’s ViewModel and iOS’s view lifecycle. Never use GlobalScope in shared modules. I know it’s tempting for “just a quick fire-and-forget call.” It’s never just one.
Design for Sendable from day one. Immutable data classes for cross-boundary types, Swift actors for mutable shared state. Retrofitting Sendable conformance into an existing KMP project is painful in a way that’s hard to appreciate until you’re knee-deep in compiler errors across 30 files.
The KMP async bridging story has gotten genuinely good over the past year. With the new Kotlin/Native memory model, SKIE, and Swift 6’s structured concurrency, we have the tools to build shared modules that feel right on both platforms. There are still rough edges (automatic Sendable inference and typed throws in bridged async calls being the big ones), but I’d take today’s toolchain over what we had two years ago without hesitation.