MVP Factory
ai startup development

Swift 6 + Kotlin coroutines: fixing KMP data races

KW
Krystian Wiewiór · · 5 min read

Meta description: Patterns to bridge Swift 6 strict concurrency and Kotlin coroutines in KMP: checked continuations, AsyncStream adapters, and actor isolation.

Tags: kotlin, swift, kmp, multiplatform, architecture


TL;DR

Swift 6’s strict concurrency checking turns every KMP-exported Kotlin API into a potential compiler error. Kotlin’s suspend functions become completion handlers that aren’t @Sendable, Flow types lack AsyncSequence conformance, and shared data classes fail Sendable checks. Three wrapper patterns fix this: checked continuations, AsyncStream adapters, and actor-isolated repositories. They eliminate the warnings while preserving structured concurrency on both sides.


The collision no one prepared for

Kotlin Multiplatform promised shared business logic. Swift 6 promised data race safety at compile time. What nobody mentioned is that these two guarantees clash at the platform boundary.

When you export a Kotlin suspend fun getUser(id: String): User to Swift, SKIE or the Kotlin/Native compiler generates an Objective-C method with a completion handler. Swift 6 immediately flags this: the closure isn’t marked @Sendable, and the returned User type doesn’t conform to Sendable. You’re stuck before you’ve written a single line of Swift.

This matters because concurrency bugs multiply. Every data race you eliminate at the boundary prevents a cascade of issues downstream. Get this layer right once, and every module you add inherits the safety guarantees automatically.

Where the friction actually lives

Here’s what breaks, mapped to severity:

KMP exportSwift 6 problemSeverityFix pattern
suspend funCompletion handler not @SendableBuild errorChecked continuation wrapper
Flow<T>No AsyncSequence conformanceBuild errorAsyncStream adapter
data classNot SendableWarning → error@unchecked Sendable or actor isolation
Shared mutable stateGlobal actor isolation mismatchBuild errorActor-isolated repository
Callback-based APIs@Sendable closure requirementsBuild errorwithCheckedContinuation

In my experience with production KMP apps, the majority of Swift 6 migration effort lands in this interop layer, not in pure Swift code. On the last project I shipped, it accounted for the bulk of concurrency-related fixes.

Pattern 1: Checked continuations for suspend functions

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

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)
                }
            }
        }
    }
}

enum KMPBridgeError: Error {
    case unexpectedNilResult
}

The actor boundary gives you Sendable isolation automatically. The checked continuation bridges the callback into structured concurrency. 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 @unchecked escape hatches.

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

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. When I switched from raw callback forwarding to this pattern, concurrency-related crashes dropped noticeably. Cancellation and lifecycle get handled in one place instead of scattered across call sites.

Pattern 3: Actor-isolated repositories

The mistake I keep seeing: teams sprinkle @MainActor on individual view models instead of isolating at the repository layer. That creates a web of implicit main-thread dependencies.

Instead, create a single actor that owns all SDK access:

@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]> { /* ... */ }
}

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

Why this pays off

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

I won’t pretend it’s painless though. You’ll spend a few frustrating days getting the first module through the compiler. The error messages for actor isolation violations are… not great. But once you internalize the patterns, subsequent modules go fast.

Takeaways

  1. Wrap at the boundary, not the call site. Build a single actor-isolated repository layer that adapts all KMP exports to Swift’s structured concurrency. Don’t scatter withCheckedContinuation across view models.

  2. Prefer AsyncStream over raw callbacks for flows. It preserves cancellation semantics, composes with for await, and satisfies Sendable requirements without escape hatches.

  3. Invest in the interop layer early. Race conditions multiply as your shared module surface grows, and retrofitting this stuff is miserable. Treat it as infrastructure, not boilerplate.


Share: Twitter LinkedIn