MVP Factory
Bridging Kotlin Coroutines and Swift 6 Structured Concurrency in KMP: Building Leak-Free Shared Async APIs
ai startup development

Bridging Kotlin Coroutines and Swift 6 Structured Concurrency in KMP: Building Leak-Free Shared Async APIs

KW
Krystian Wiewiór · · 6 min read

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.

ChallengeKotlin sideSwift 6 side
Async functionssuspend funNeeds async / await
Reactive streamsFlow<T>Needs AsyncSequence
Thread safetyCoroutine dispatchersActor isolation + Sendable
CancellationCoroutineScope.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:

  1. Mark Kotlin-generated types as @unchecked Sendable on the Swift side. If your Kotlin data classes are effectively immutable (all val properties, 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.

  1. 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

TrapSymptomFix
Unscoped GlobalScope.launch in shared codeMemory leak on iOS, coroutine runs after view diesAlways use PlatformScope
Non-Sendable types across actor boundariesSwift 6 compiler error@unchecked Sendable for immutable types or actor wrapping
Flow.collect without cancellationCollector never terminates on iOSUse SKIE’s AsyncSequence bridging + Task cancellation
Dispatchers.Main on background Kotlin/Native threadCrash: IncorrectDereferenceExceptionEnsure initial call originates from main thread or use Dispatchers.Main.immediate
Ignoring back-pressure on high-frequency FlowsUI jank and dropped framesUse 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.


Share: Twitter LinkedIn