DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Structured Concurrency in Swift 6 vs. Kotlin Coroutines: What Your KMP Team Needs to Unlearn

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 suspend functions and Swift's async/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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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()
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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: Flow becomes AsyncSequence in Swift, but emitted values must be Sendable. Kotlin data classes with var properties fail this check. Use val only in shared types.
  • Cancellation asymmetry: Task.cancel() in Swift and Job.cancel() in Kotlin propagate differently through bridged code. Write cancellation tests on both platforms independently — aim for response under 100ms.
  • Main actor hops: When @MainActor Swift code calls a SKIE-bridged function, the return hop to main can cause frame drops if Kotlin used Dispatchers.Default internally. 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)