---
title: "Bridging Kotlin Coroutines and Swift 6 Concurrency in KMP"
published: true
description: "A step-by-step workshop for exposing leak-free async APIs from KMP shared modules — mapping Flows to AsyncSequence, handling Sendable, and scoping coroutines to platform lifecycles."
tags: kotlin, swift, architecture, mobile
canonical_url: https://blog.mvp-factory.com/bridging-kotlin-coroutines-swift-concurrency-kmp
---
## What we are building
In this workshop, we will wire up a Kotlin Multiplatform shared module so that `suspend` functions become Swift `async`/`await` calls and `Flow` streams become `AsyncSequence` — all without leaking coroutines when a SwiftUI view disappears or an Android ViewModel clears. By the end you will have a three-layer bridging architecture you can drop into any production KMP project.
Let me show you a pattern I use in every project.
## Prerequisites
- Kotlin 2.0+ with the new Native memory model enabled (default since 1.7.20)
- Swift 6 with strict concurrency checking turned on
- A KMP project targeting at least Android and iOS
- [SKIE](https://skie.touchlab.co/) added to your Gradle build (Touchlab's open-source bridging plugin)
## Step 1 — Let SKIE generate idiomatic Swift signatures
Raw Kotlin `suspend` functions compile to Objective-C completion handlers. Swift 6 hates them. SKIE rewrites the generated headers so Swift sees native `async` signatures instead.
kotlin
// shared/src/commonMain/kotlin/UserRepository.kt
class UserRepository(private val api: UserApi) {
suspend fun getUser(id: String): User = api.fetchUser(id)
fun observeUsers(): Flow> = api.usersFlow()
}
With SKIE enabled, the Swift call site looks like this — no manual wrapping required:
swift
let user = try await repository.getUser(id: "123")
for await users in repository.observeUsers() {
updateUI(users)
}
On a project with 42 shared use cases, this cut our Swift-side glue code from roughly 1,800 lines to under 200. The docs do not mention this, but SKIE also handles `sealed class` hierarchies as Swift enums with associated values — a nice bonus.
## Step 2 — Scope every coroutine to platform lifecycle
Here is the gotcha that will save you hours: an unscoped `CoroutineScope` inside shared code leaks coroutines when the host view dies. I have debugged this exact issue across three production apps, including [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk), where a timer-based Flow kept firing break reminders after the user had navigated away.
Here is the minimal setup to get this working — an `expect/actual` scope tied to each platform's lifecycle:
kotlin
// commonMain
expect class PlatformScope() {
val scope: CoroutineScope
fun cancel()
}
// androidMain
actual class PlatformScope {
actual val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
actual fun cancel() { scope.cancel() }
}
// iosMain
actual class PlatformScope {
private val job = SupervisorJob()
actual val scope = CoroutineScope(job + Dispatchers.Main)
actual fun cancel() { job.cancel() }
}
On Android, call `cancel()` from `onCleared()`. On iOS, tie it to `deinit` or the `.task` modifier's cooperative cancellation. Every coroutine launched in shared code flows through this scope. No orphans.
## Step 3 — Satisfy Swift 6 Sendable requirements
Any type crossing a Swift isolation boundary must be `Sendable`. Kotlin-generated classes are not. Two strategies that work:
**Immutable data classes** — assert conformance on the Swift side:
kotlin
data class User(val id: String, val name: String, val email: String)
swift
extension User: @unchecked Sendable {}
Audit each type. If a Kotlin class holds mutable state, this annotation hides real bugs.
**Mutable shared state** — wrap in a Swift actor:
swift
actor UserStore {
private let repository: UserRepository
func loadUser(id: String) async throws -> User {
try await repository.getUser(id: id)
}
}
This keeps your shared Kotlin code free of platform-specific annotations.
## Gotchas
| Trap | Symptom | Fix |
|---|---|---|
| `GlobalScope.launch` in shared code | Memory leak on iOS; coroutine survives view | Always route through `PlatformScope` |
| Non-Sendable type across actor boundary | Swift 6 compiler error | `@unchecked Sendable` for immutable types, actor wrapping for mutable |
| `Flow.collect` without cancellation | Collector never terminates on iOS | Use SKIE's `AsyncSequence` bridging + Task cancellation |
| `Dispatchers.Main` called from background thread | `IncorrectDereferenceException` crash | Ensure initial call is on main thread or use `Dispatchers.Main.immediate` |
| High-frequency Flow without back-pressure | UI jank, dropped frames | Apply `conflate()` or `debounce()` before exposing to Swift |
## Wrapping up
The pattern is: **SKIE for bridging, expect/actual for lifecycle scoping, Sendable-by-design for thread safety.** Adopt these three layers and your shared module will feel native on both platforms.
Design for `Sendable` from day one. Retrofitting it into an existing project across 30 files is a pain you want to avoid.
The KMP async story has gotten genuinely good. With the new memory model, SKIE, and Swift 6 structured concurrency, we finally have the tools to ship shared modules that feel right everywhere. Start with SKIE — the reduction in glue code alone justifies the dependency.
**Resources:**
- [SKIE documentation](https://skie.touchlab.co/)
- [Kotlin expect/actual declarations](https://kotlinlang.org/docs/multiplatform-expect-actual.html)
- [Swift 6 Sendable documentation](https://developer.apple.com/documentation/swift/sendable)
Top comments (0)