---
title: "Bridging Kotlin Coroutines and Swift Concurrency in KMP"
published: true
description: "Production-tested patterns for exposing Kotlin Flow and suspend functions to Swift 6 — SKIE vs manual wrappers, cancellation, and memory pitfalls."
tags: kotlin, swift, ios, mobile
canonical_url: https://blog.mvpfactory.co/bridging-kotlin-coroutines-and-swift-concurrency-in-kmp
---
## What We Are Building
Let me show you the patterns I use in every KMP project to bridge Kotlin coroutines into Swift structured concurrency — without leaking coroutines, dropping signals, or fighting `@MainActor` isolation. By the end, you will know when to reach for SKIE, when to write manual wrappers, and how to avoid the cancellation bugs that account for a shocking number of iOS-side crashes.
## Prerequisites
- A Kotlin Multiplatform project targeting iOS
- Familiarity with Kotlin `Flow` and `suspend` functions
- Basic Swift concurrency knowledge (`async/await`, `Task`)
- Xcode 15+ with Swift 5.9 or Swift 6
## Step 1: Choose Your Bridging Strategy
Touchlab's [SKIE](https://skie.touchlab.co/) automatically converts `suspend fun` to `async` and `Flow<T>` to `AsyncSequence`. For most teams, it is the right default — five minutes of Gradle setup versus fifteen lines of boilerplate per function.
Here is the minimal setup to get this working. Add the SKIE plugin to your shared module:
kotlin
plugins {
id("co.touchlab.skie") version "0.9.5"
}
That is it. Your Swift code now sees `async` functions instead of completion handlers.
But here is the gotcha that will save you hours: **SKIE does not handle cancellation identically to Swift structured concurrency**. The gap is subtle and it bites you in production.
## Step 2: Fix Cancellation Propagation
This pattern looks harmless but leaks coroutines:
swift
// DANGEROUS: cancellation may not propagate
let stream = viewModel.priceUpdates()
Task {
for await price in stream {
updateUI(price)
}
}
The flow is created *outside* the `Task`. When the `Task` cancels, the Kotlin coroutine keeps running. The fix is simple — create the flow inside the task scope:
swift
// CORRECT: flow creation inside the Task scope
Task {
for await price in viewModel.priceUpdates() {
updateUI(price)
}
}
For manual wrappers where you need full control, track the `Job` explicitly:
kotlin
fun priceUpdatesWrapper(): AsyncStream {
return AsyncStream { continuation ->
val job = scope.launch {
priceFlow.collect { continuation.yield(it) }
}
continuation.onTermination = { job.cancel() }
}
}
## Step 3: Handle Back-Pressure at the Kotlin Boundary
SKIE defaults to a buffered channel with capacity 64. For UI state, that is fine. For high-throughput streams — sensor data, WebSocket ticks, real-time analytics — you are silently dropping signals or eating memory.
I work on KMP apps with constant data flows (during long architecture sessions I rely on [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) to nudge me into actually moving — the chair is the architect's real enemy). For streams like accelerometer readings or timer ticks, conflate at the Kotlin boundary:
kotlin
val sensorData: Flow = rawSensorFlow
.conflate()
.flowOn(Dispatchers.Default)
The docs do not mention this, but back-pressure decisions belong in shared code where you understand the data semantics. Do not rely on SKIE's default buffer for streams it was not designed for.
## Step 4: Solve the MainActor Isolation Problem
Kotlin's `Dispatchers.Main` and Swift's `@MainActor` are *not the same dispatcher*. This causes a thread hop that Swift 6 strict concurrency flags as an isolation violation:
swift
@MainActor
class ViewModel: ObservableObject {
func load() async {
// Returns on Kotlin's Main — Swift 6 flags this
self.data = try await shared.fetchData()
}
}
The fix: return on an unconfined dispatcher and let Swift handle isolation.
kotlin
suspend fun fetchData(): Data = withContext(Dispatchers.Unconfined) {
repository.getData()
}
## Gotchas
- **Leaked coroutines do not crash.** They silently drain battery. Write integration tests that cancel Swift `Task`s mid-collection and verify the Kotlin `Job` actually terminates.
- **`@SharedImmutable` legacy contamination.** Even with the new memory model, transitive dependencies using `@SharedImmutable` will throw `InvalidMutabilityException` on mutation from Swift callbacks. Audit with `grep -r "SharedImmutable" shared/build/classes/`.
- **Build time cost.** SKIE adds 8–15% to iOS framework compilation. Budget for this in CI.
- **Do not mix strategies randomly.** Pick SKIE as your default, then drop to manual wrappers only for high-throughput streams or complex cancellation graphs.
## Quick Decision Framework
| Scenario | Use |
|---|---|
| Small team, < 20 shared APIs | SKIE, default config |
| High-throughput streams | SKIE + manual `conflate()`/`buffer()` |
| Complex cancellation graphs | Manual wrappers with explicit `Job` |
| Swift 6 strict concurrency | Manual wrappers + `Dispatchers.Unconfined` |
## Wrapping Up
Start with SKIE — it handles 80% of cases correctly. Then audit your cancellation paths, conflate your hot flows at the Kotlin boundary, and use `Dispatchers.Unconfined` for anything consumed by `@MainActor`. These three patterns eliminated the majority of our iOS-side coroutine bugs across three production KMP apps.
The concurrency boundary is KMP's hardest integration surface. Get it right once, and everything downstream gets easier.
Top comments (0)