DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Swift 6 Strict Concurrency Meets Kotlin Coroutines in KMP

---
title: "Swift 6 + Kotlin Coroutines: Fixing KMP Data Races with Three Wrapper Patterns"
published: true
description: "Patterns to bridge Swift 6 strict concurrency and Kotlin coroutines in KMP: checked continuations, AsyncStream adapters, and actor isolation."
tags: kotlin, swift, mobile, architecture
canonical_url: https://blog.mvpfactory.co/swift-6-kotlin-coroutines-fixing-kmp-data-races
---

## What We're Building

Let me show you a pattern I use in every KMP project now. We'll build a concurrency-safe interop layer that bridges Kotlin `suspend` functions and `Flow` types into Swift 6's strict concurrency model — without a single `@unchecked Sendable` escape hatch.

By the end of this tutorial, you'll have three reusable wrapper patterns: checked continuations for suspend functions, `AsyncStream` adapters for flows, and an actor-isolated repository that ties everything together.

## Prerequisites

- A Kotlin Multiplatform project exporting a shared module to iOS
- Swift 6 strict concurrency checking enabled
- Familiarity with Kotlin coroutines (`suspend`, `Flow`) and Swift's `async/await`

## The Problem, in One Table

When you export Kotlin APIs to Swift, the generated Objective-C bridge clashes with Swift 6's compile-time data race safety. Here's the damage:

| KMP export | Swift 6 problem | Severity | Fix pattern |
|---|---|---|---|
| `suspend fun` | Completion handler not `@Sendable` | Build error | Checked continuation wrapper |
| `Flow<T>` | No `AsyncSequence` conformance | Build error | `AsyncStream` adapter |
| `data class` | Not `Sendable` | Warning → error | `@unchecked Sendable` or actor isolation |
| Shared mutable state | Global actor isolation mismatch | Build error | Actor-isolated repository |
| Callback-based APIs | `@Sendable` closure requirements | Build error | `withCheckedContinuation` |

In production KMP apps, the majority of Swift 6 migration effort lands in this interop layer, not in pure Swift code. Here's the minimal setup to get this working.

## Step 1: Checked Continuations for Suspend Functions

Wrap every exported `suspend` function in a Swift actor that bridges through `withCheckedThrowingContinuation`:

Enter fullscreen mode Exit fullscreen mode


swift
actor UserRepository {
private let sdk: SharedUserSDK

func getUser(id: String) async throws -> User {
    try await withCheckedThrowingContinuation { continuation in
        sdk.getUser(id: id) { user, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let user {
                continuation.resume(returning: user)
            } else {
                continuation.resume(throwing: KMPBridgeError.unexpectedNilResult)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

}

enum KMPBridgeError: Error {
case unexpectedNilResult
}


The actor boundary gives you `Sendable` isolation automatically. That `else` clause handles the edge case where Objective-C completion handlers deliver `nil` for both value and error — without it, the continuation leaks silently. No force-casts, no escape hatches.

## Step 2: AsyncStream Adapters for Flows

Kotlin `Flow` exports are the worst offender. The generated Objective-C interface gives you a collector callback with no `AsyncSequence` conformance. The fix:

Enter fullscreen mode Exit fullscreen mode


swift
extension UserRepository {
func observeUsers() -> AsyncStream<[User]> {
AsyncStream { continuation in
let job = sdk.observeUsers().collect { users in
continuation.yield(users)
}
continuation.onTermination = { _ in job.cancel() }
}
}
}


This gives your SwiftUI views a clean `for await` interface while respecting cancellation on both sides. Switching from raw callback forwarding to this pattern noticeably reduces concurrency-related crashes — cancellation and lifecycle get handled in one place.

## Step 3: Actor-Isolated Repository

The docs don't mention this, but sprinkling `@MainActor` on individual view models instead of isolating at the repository layer creates a web of implicit main-thread dependencies. Instead, create a single actor that owns all SDK access:

Enter fullscreen mode Exit fullscreen mode


swift
@globalActor actor SharedSDKActor {
static let shared = SharedSDKActor()
}

@SharedSDKActor
final class KMPRepository {
private let sdk: SharedSDK

func getUser(id: String) async throws -> User { /* ... */ }
func observeUsers() -> AsyncStream<[User]> { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

}


View models annotated with `@MainActor` call into `KMPRepository` across actor boundaries. Swift 6's compiler enforces the handoff. No data races, no runtime surprises.

## Gotchas

**The nil/nil completion handler.** Objective-C bridges can call your completion handler with `nil` for both the result and the error. If you don't handle this, `withCheckedContinuation` will never resume — and you'll get a silent hang, not a crash. Always add the `else` clause.

**Don't scatter `withCheckedContinuation` across view models.** Wrap at the boundary once. If every call site does its own bridging, you're multiplying the surface area for mistakes.

**Actor isolation error messages are rough.** You'll spend frustrating time deciphering them during your first module. Push through — subsequent modules go fast once you internalize the patterns.

**Cancellation propagation.** The `onTermination` handler on `AsyncStream` is your only hook to cancel the underlying Kotlin `Job`. Miss it and you've got a leaked coroutine collecting data nobody reads. This is the kind of subtle bug that only shows up in long sessions — I actually caught one during an extended coding block when [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) reminded me to take a break, and I came back with fresh eyes on the memory graph.

## Conclusion

This layered approach takes real upfront investment, but the payoff compounds. Once the interop layer is solid, every new KMP module slots in cleanly and inherits concurrency safety without extra work.

Three rules to take with you:

1. **Wrap at the boundary, not the call site.** Build a single actor-isolated repository layer.
2. **Prefer `AsyncStream` over raw callbacks** for flows — it preserves cancellation and satisfies `Sendable`.
3. **Invest in the interop layer early.** Race conditions multiply as your shared module surface grows, and retrofitting is miserable.

Treat this layer as infrastructure, not boilerplate. Your future self will thank you.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)