DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Bridging Kotlin Coroutines and Swift Concurrency in KMP: What Actually Works in Production

---
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:

Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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:

Enter fullscreen mode Exit fullscreen mode


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.

Enter fullscreen mode Exit fullscreen mode


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.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)