What We Will Build
Let me show you a pattern I use in every production KMP project: a concurrency-at-the-edges architecture that keeps your shared Kotlin module clean while letting Swift 6 and Android each own their concurrency models.
By the end, you will understand exactly where Swift 6 and Kotlin coroutines disagree, why your current shared concurrency code is a ticking bug factory, and how to restructure it in under an hour.
Prerequisites
- A working KMP project targeting iOS and Android
- Kotlin 1.9+ with Kotlin/Native
- Xcode 16+ with Swift 6 strict concurrency enabled
- Familiarity with
suspendfunctions and Swift'sasync/await
Step 1: Understand Where the Models Diverge
Here is the gotcha that will save you hours. Swift 6 and Kotlin coroutines look similar on the surface, but they enforce safety at fundamentally different levels:
| Concern | Kotlin | Swift 6 |
|---|---|---|
| Data race prevention | Runtime discipline |
Compile-time (Sendable checking) |
| Scope enforcement | Convention-based | Compiler-enforced |
| Thread control |
Dispatchers.IO, Dispatchers.Default
|
Cooperative pool (no direct control) |
| Cancellation | Cooperative (ensureActive()) |
Cooperative + automatic in task groups |
The docs do not mention this, but Swift 6's Sendable checking will reject most Kotlin/Native generated classes at the bridge boundary. That is the root cause of half the issues KMP teams file against SKIE.
Step 2: Identify the Leaky Abstractions
Stop sharing concurrency primitives. Here is the code that looks fine on Android but breaks on iOS:
// shared/commonMain — DO NOT do this
class ArticleRepository(private val api: ArticleApi) {
suspend fun fetchArticles(): List<Article> = withContext(Dispatchers.Default) {
api.getArticles()
}
}
That withContext(Dispatchers.Default) embeds a threading assumption. When SKIE bridges this to Swift async, the result crosses an isolation boundary. If Article is not Sendable-conformant — and generated Kotlin/Native classes are not by default — Swift 6 gives you a compiler error that does not exist on Android.
The same problem hits expect/actual dispatcher patterns:
// This compiles but fights Swift 6's execution model
expect val backgroundDispatcher: CoroutineDispatcher
Swift 6 discourages direct thread management. Your dispatcher abstraction works against the platform instead of with it.
Step 3: Apply the Concurrency-at-the-Edges Pattern
Here is the minimal setup to get this working. Your shared module exposes plain suspend functions returning immutable data. No dispatchers, no scopes, no Flow in the public API:
// shared/commonMain — concurrency agnostic
class ArticleRepository(private val api: ArticleApi) {
suspend fun fetchArticles(): List<Article> = api.getArticles()
}
Each platform wraps calls in its own concurrency primitive:
// iOS — Swift 6 owns concurrency
@MainActor
class ArticleViewModel: ObservableObject {
@Published var articles: [Article] = []
func load() {
Task {
let articles = try await repository.fetchArticles()
self.articles = articles
}
}
}
// Android — Kotlin owns concurrency
class ArticleViewModel(private val repo: ArticleRepository) : ViewModel() {
val articles = MutableStateFlow<List<Article>>(emptyList())
fun load() {
viewModelScope.launch {
articles.value = repo.fetchArticles()
}
}
}
Step 4: Audit Your SKIE Boundary
Every type crossing the Swift 6 bridge must be Sendable. For your shared data classes, either generate Swift structs or use SKIE's sealed type mapping. Do not rely on default class bridging.
Gotchas
-
SKIE Flow wrapping:
FlowbecomesAsyncSequencein Swift, but emitted values must beSendable. Kotlin data classes withvarproperties fail this check. Usevalonly in shared types. -
Cancellation asymmetry:
Task.cancel()in Swift andJob.cancel()in Kotlin propagate differently through bridged code. Write cancellation tests on both platforms independently — aim for response under 100ms. -
Main actor hops: When
@MainActorSwift code calls a SKIE-bridged function, the return hop to main can cause frame drops if Kotlin usedDispatchers.Defaultinternally. This is why the shared layer should specify no dispatcher at all. -
coroutineScope {}opacity: Scoped blocks in shared code become invisible to Swift's task tree. Cancellation will not propagate the way your iOS developer expects.
Conclusion
The structured concurrency gap between Swift 6 and Kotlin is not a bug — it is a design boundary. Respect it by keeping concurrency out of your shared module entirely. Push CoroutineScope, Dispatchers, Task, and actors to platform code. Your shared layer becomes simpler, testable, and immune to the next round of platform concurrency changes.
Let me know if you want me to walk through the cancellation testing setup next — that one deserves its own workshop.
Top comments (0)