Coroutines, Flow and Concurrency in Shared Code
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Concurrency is where shared Kotlin code meets four very different runtimes: the Android main thread and ART, the
iOS main queue and Grand Central Dispatch, the desktop JVM with a Swing event thread, and the single-threaded
browser. The good news is that kotlinx-coroutines is a multiplatform library: the same suspend functions,
Flow operators, CoroutineScope and Job hierarchy compile unchanged in commonMain and run on every
target. The platform differences are confined to a small number of places — which dispatchers exist, which thread
Dispatchers.Main means, and how Swift code consumes a flow — and this page walks through all of them.
The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1,
AGP 9.4, Ktor 3.6.0, SQLDelight 2.4.0, Koin 4.2.2, SKIE 0.10.15, KMP-NativeCoroutines
(1.0.6) and Turbine 1.2.1. Swift export and SwiftPM import are Alpha; Wasm and Compose Multiplatform for web are
Beta. Every example is a slice of TrailMate Everywhere, the multiplatform version of the Android section’s
TrailMate app (package com.example.trailmate). The coroutine and Flow language
fundamentals are not re-taught here — read Coroutines
Basics and Flows first. The layout of source sets is explained on
Project Structure, Targets, Source Sets and
the Hierarchy Template.
Adding kotlinx-coroutines to commonMain
Coroutines are a library, not part of the language beyond the suspend keyword. In a Kotlin Multiplatform module
you add kotlinx-coroutines-core to commonMain; Gradle metadata then resolves the correct platform artifact
for every target, so you never name the Android, iOS or JS variants yourself.
// sharedLogic/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) // com.android.kotlin.multiplatform.library
}
kotlin {
androidLibrary {
namespace = "com.example.trailmate.shared"
compileSdk = 37
minSdk = 26
}
iosArm64()
iosSimulatorArm64()
jvm("desktop")
wasmJs { browser() }
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
}
// Dispatchers.Main on desktop needs a UI-toolkit-specific artifact (see below).
val desktopMain by getting {
dependencies {
implementation(libs.kotlinx.coroutines.swing)
}
}
}
}
Three details are worth knowing. First, kotlinx-coroutines-core is Stable (the library ships its own
@ExperimentalCoroutinesApi and @DelicateCoroutinesApi markers for the few operators that are not). Second, on
Android the real Dispatchers.Main comes from kotlinx-coroutines-android, which the AndroidX lifecycle and
Compose artifacts already pull in; if your Android code runs Dispatchers.Main outside a ViewModel, add it
explicitly in androidMain. Third, the version of kotlinx-coroutines is chosen by your Gradle graph, not by the
Kotlin Gradle plugin, so keep it in the version catalog and align it with the libraries that depend on it (Ktor,
SQLDelight and Compose all use it).
Dispatchers on Every Target
A dispatcher decides which thread or threads a coroutine runs on. The four standard dispatchers exist in common code, but what each one means depends on the target.
| Dispatcher | Android | iOS (Kotlin/Native) | Desktop JVM | wasmJs |
|---|---|---|---|---|
|
Main looper (needs |
The main queue ( |
Throws |
The single browser thread; always available. |
|
Shared background thread pool sized to the CPU count. |
Shared background worker pool sized to the CPU count. |
Shared background thread pool. |
Same single thread as |
|
Elastic pool for blocking calls (64 threads by default). |
Available in common code since kotlinx-coroutines 1.7; a pool for blocking calls (64 workers by default). |
Elastic pool for blocking calls. |
Available as an alias of |
Main on iOS: the main queue
On iOS, Dispatchers.Main dispatches to the main queue. UIKit and SwiftUI also require UI work on that queue, so
a coroutine launched on Dispatchers.Main can update observable state that SwiftUI renders without any extra
hopping. No extra dependency is needed: the Native implementation of Dispatchers.Main is part of
kotlinx-coroutines-core. Remember that the main queue is only drained while the app’s run loop is running:
in a command-line Kotlin/Native executable runBlocking on Dispatchers.Main would deadlock, but inside an iOS
app the run loop is always active.
IO on Native
Historically Dispatchers.IO was a JVM-only name, so common code had to write an expect val ioDispatcher.
Since kotlinx-coroutines 1.7 it is declared for every target, which removes the most common reason to write
expect/actual for dispatchers (see
expect/actual and Calling Platform APIs).
On Native it is a worker pool intended for blocking calls such as file or SQLite access. Use it for blocking
work only: Ktor and SQLDelight’s suspending APIs already move themselves off the caller thread, so wrapping them
in withContext(Dispatchers.IO) is redundant.
Desktop Main needs a toolkit artifact
A desktop JVM app has no built-in notion of a main thread. kotlinx-coroutines-swing supplies
Dispatchers.Main backed by the Swing event dispatch thread; Compose Multiplatform for Desktop uses Skia over
AWT, so it is the right choice for TrailMate’s desktopApp. If you forget it, the first use of
Dispatchers.Main or viewModelScope throws Module with the Main dispatcher is missing. The artifact is
declared in desktopMain of the shared module, as in the Gradle snippet above. See
Kotlin docs — Create your Compose Multiplatform app.
Injecting dispatchers
Hard-coding Dispatchers.IO inside a repository makes it untestable and makes platform differences leak
everywhere. The idiomatic fix is a tiny interface that production code binds to the real dispatchers and tests bind
to a TestDispatcher.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/core/DispatcherProvider.kt
package com.example.trailmate.core
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
interface DispatcherProvider {
val main: CoroutineDispatcher
val io: CoroutineDispatcher
val default: CoroutineDispatcher
}
class DefaultDispatcherProvider : DispatcherProvider {
override val main: CoroutineDispatcher = Dispatchers.Main
override val io: CoroutineDispatcher = Dispatchers.IO
override val default: CoroutineDispatcher = Dispatchers.Default
}
Bind DefaultDispatcherProvider in the Koin trailMateModule (see
Koin — Kotlin Multiplatform); the testing side of this arrangement is covered on
Testing Shared Code.
Structured Concurrency in Shared ViewModels
Structured concurrency means every coroutine has a parent scope and cannot outlive it: cancelling the scope cancels all children, and a scope does not complete until its children do. It is the reason leaks of background work are rare in Kotlin, and in a shared module it has one more benefit: the same ownership rules apply on Android, iOS and desktop, so a screen that goes away stops its network call everywhere.
In KMP the owner of that scope is normally an androidx.lifecycle.ViewModel. The lifecycle-viewmodel artifact is
multiplatform, and its viewModelScope is a CoroutineScope built on SupervisorJob(): it is cancelled when the ViewModel is cleared. The architecture side is covered on
Architecture, Shared ViewModels and State;
here we focus on the concurrency behaviour.
Dispatchers.Main.immediate
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/TrailListViewModel.kt
package com.example.trailmate.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.trailmate.domain.Trail
import com.example.trailmate.domain.TrailRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
sealed interface TrailListUiState {
data object Loading : TrailListUiState
data class Success(val trails: List<Trail>) : TrailListUiState
data class Error(val message: String) : TrailListUiState
}
class TrailListViewModel(
private val repository: TrailRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<TrailListUiState>(TrailListUiState.Loading)
val uiState: StateFlow<TrailListUiState> = _uiState.asStateFlow()
private var refreshJob: Job? = null
init {
refresh()
}
fun refresh(region: String? = null) {
refreshJob?.cancel() // only one refresh at a time; the previous one is cancelled
refreshJob = viewModelScope.launch {
_uiState.update { TrailListUiState.Loading }
try {
val trails = repository.refreshTrails(region) // suspend; main-safe
_uiState.update { TrailListUiState.Success(trails) }
} catch (e: CancellationException) {
throw e // never swallow cancellation
} catch (e: Exception) {
_uiState.update { TrailListUiState.Error(e.message ?: "Unknown error") }
}
}
}
}
See Android Developers — ViewModel in Kotlin Multiplatform and Kotlin docs — Structured concurrency.
Four rules make this ViewModel correct on every platform.
-
Launch only from a scope with an owner.
viewModelScopeis tied to the ViewModel;GlobalScopeis not tied to anything and is marked@DelicateCoroutinesApi. A coroutine launched in it keeps running after the screen is gone, and on iOS keeps the framework objects alive. -
Make suspending functions main-safe.
repository.refreshTrails()switches threads internally (Ktor and SQLDelight suspend off the caller); the ViewModel never needs to know which dispatcher is behind it. This is the same convention Google recommends for Android. -
Rethrow
CancellationException. Cancellation is delivered as an exception; catchingExceptionblindly and not rethrowing it turns a cancelled job into a zombie that carries on and writes stale state. -
Update state atomically.
MutableStateFlow.update { }is a compare-and-set loop and is safe if two coroutines race;value = value.copy(…)is not.
Parallel work and failure scope
Inside a suspend function, use coroutineScope { } to start children that must all succeed together. If one
child fails, its siblings are cancelled and the exception propagates to the caller. Use supervisorScope { } only
when siblings are independent.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/OfflineFirstTrailRepository.kt
package com.example.trailmate.data
import com.example.trailmate.core.DispatcherProvider
import com.example.trailmate.domain.Trail
import com.example.trailmate.domain.TrailRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class OfflineFirstTrailRepository(
private val api: TrailApiClient,
private val db: TrailMateDatabase,
private val dispatchers: DispatcherProvider,
) : TrailRepository {
override fun observeTrails(): Flow<List<Trail>> =
db.trailQueries.selectAll().asFlow() // SQLDelight coroutines extension
.mapToList(dispatchers.io)
.map { rows -> rows.map { it.toDomain() } }
override suspend fun refreshTrails(region: String?): List<Trail> = coroutineScope {
// Both requests run in parallel; if either fails the other is cancelled.
val remote = async { api.fetchTrails(page = 1, region = region) }
val favorites = async { withContext(dispatchers.io) { db.trailQueries.favoriteIds().executeAsList() } }
val favoriteIds = favorites.await().toSet()
val trails = remote.await().map { dto -> dto.toDomain(isFavorite = dto.id in favoriteIds) }
withContext(dispatchers.io) { db.trailQueries.transaction { trails.forEach(db::upsert) } }
trails
}
}
StateFlow and SharedFlow
Shared code exposes UI state as a Flow; the two hot flows that matter are StateFlow and SharedFlow. Both
are Stable, both live in kotlinx-coroutines-core, and both are the types the Swift interop tools understand.
StateFlow |
SharedFlow |
|
|---|---|---|
Initial value |
Required; |
None; consumers wait for the first emission. |
Replay |
Exactly one (the latest value). |
Configurable ( |
Equal values |
Conflated: an equal value is not re-emitted. |
Every emission is delivered. |
Slow collectors |
Always see the newest value; intermediate values are dropped. |
Governed by |
Typical use |
Screen state ( |
One-off events (navigate, show snackbar), when a channel is not preferred. |
Exposing state from a cold flow
When the source is a database Flow, stateIn converts it into a StateFlow with a lifecycle policy.
SharingStarted.WhileSubscribed(5_000) keeps the upstream running for five seconds after the last collector
leaves, so a configuration change or a brief iOS view re-creation does not restart the query.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/presentation/FavoriteTrailsViewModel.kt
package com.example.trailmate.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.trailmate.domain.Trail
import com.example.trailmate.domain.TrailRepository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class FavoriteTrailsViewModel(private val repository: TrailRepository) : ViewModel() {
val favorites: StateFlow<List<Trail>> = repository.observeTrails()
.map { trails -> trails.filter { it.isFavorite } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
initialValue = emptyList(),
)
// One-off events: a Channel delivers each event to exactly one collector.
private val _events = Channel<String>(Channel.BUFFERED)
val events: Flow<String> = _events.receiveAsFlow()
fun toggleFavorite(trail: Trail) {
viewModelScope.launch {
repository.setFavorite(trail.id, !trail.isFavorite)
_events.send("Updated ${trail.name}")
}
}
}
A SharedFlow with replay = 0 loses events fired while nobody collects; a Channel buffers them but delivers
each to one collector. Events that must not be lost are best modelled as state in TrailListUiState.
Observing Shared Flows from Swift
Kotlin/Native exports Flow to Objective-C as an opaque Kotlinx_coroutines_coreFlow whose collect takes a
completion handler and a FlowCollector that Swift has to implement. That is unusable in practice, which is
why Touchlab’s SKIE (version 0.10.15 at this baseline) and Rick Clephas’s KMP-NativeCoroutines (1.0.6) exist.
Both turn Flow into Swift AsyncSequence and suspend functions into Swift async functions, with
cancellation bridged. Swift export is the JetBrains-owned replacement for the Objective-C bridge; it is Alpha, and
its coroutines support is not yet equivalent to SKIE’s. See
Objective-C Interop, Swift Export and SKIE
for the full comparison.
// iosApp/iosApp/TrailListView.swift
import SwiftUI
import SharedLogic
@MainActor
final class TrailListObserver: ObservableObject {
@Published private(set) var state: TrailListUiState = TrailListUiStateLoading()
private let viewModel: TrailListViewModel
private var task: Task<Void, Never>?
init(viewModel: TrailListViewModel) {
self.viewModel = viewModel
}
func start() {
task = Task { [weak self] in
guard let self else { return }
// SKIE: StateFlow<T> is exposed as a SkieSwiftStateFlow<T> AsyncSequence.
for await newState in self.viewModel.uiState {
self.state = newState
}
}
}
func stop() {
task?.cancel() // cancels the underlying Flow collection in Kotlin
}
}
struct TrailListView: View {
@StateObject private var observer: TrailListObserver
init(viewModel: TrailListViewModel) {
_observer = StateObject(wrappedValue: TrailListObserver(viewModel: viewModel))
}
var body: some View {
content
.task { observer.start() }
.onDisappear { observer.stop() }
}
@ViewBuilder
private var content: some View {
switch onEnum(of: observer.state) { // SKIE: exhaustive switch over a sealed interface
case .loading: ProgressView()
case .success(let success): List(success.trails, id: \.id) { Text($0.name) }
case .error(let error): Text(error.message)
}
}
}
The class is @MainActor, so every assignment to state happens on the main thread; the shared ViewModel already
emits from Dispatchers.Main.immediate, so on iOS the two agree. Swift concurrency and Kotlin coroutines are
cooperating but separate runtimes: cancelling the Swift Task cancels the Kotlin collector, and an exception
thrown by a Kotlin function surfaces in Swift as an Error only if the function is annotated @Throws (SKIE adds
that automatically for suspend functions). See Apple Platforms for Swift concurrency
fundamentals.
Thread Confinement and UIKit
UIKit (and therefore SwiftUI and Compose Multiplatform on iOS) is main-thread confined: views, layers and view controllers may only be touched on the main thread. The Kotlin/Native bindings do not enforce this for you; an update from a background thread is a runtime error or silent corruption.
| Situation | Safe pattern |
|---|---|
Mutating UI state from a shared ViewModel |
Use |
Calling a UIKit API from |
Call it from the main dispatcher; |
Receiving a delegate callback (for example |
Callbacks arrive on the thread of the manager’s creation; bridge into a |
TrailMate’s iOS location provider shows the idiom: the delegate callback is converted to a cold Flow with
callbackFlow, registered and unregistered in awaitClose, and collected by the ViewModel on the main
dispatcher.
// sharedLogic/src/iosMain/kotlin/com/example/trailmate/location/IosLocationProvider.kt
package com.example.trailmate.location
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.useContents
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import platform.CoreLocation.CLLocation
import platform.CoreLocation.CLLocationManager
import platform.CoreLocation.CLLocationManagerDelegateProtocol
import platform.Foundation.NSError
import platform.darwin.NSObject
@OptIn(ExperimentalForeignApi::class)
class IosLocationProvider : LocationProvider {
override fun locations(): Flow<GeoPoint> = callbackFlow {
val manager = CLLocationManager()
val delegate = object : NSObject(), CLLocationManagerDelegateProtocol {
override fun locationManager(manager: CLLocationManager, didUpdateLocations: List<*>) {
val last = didUpdateLocations.lastOrNull() as? CLLocation ?: return
last.coordinate.useContents { trySend(GeoPoint(latitude, longitude)) }
}
override fun locationManager(manager: CLLocationManager, didFailWithError: NSError) {
close(IllegalStateException(didFailWithError.localizedDescription))
}
}
manager.delegate = delegate
manager.startUpdatingLocation()
awaitClose {
manager.stopUpdatingLocation()
manager.delegate = null // the manager holds its delegate weakly: keep `delegate` referenced above
}
}
}
Confinement problems rarely come from coroutines themselves; they come from platform objects with their own threading and ownership rules.
Kotlin/Native Concurrency: History and Migration
Much online material about Kotlin/Native and coroutines describes an obsolete model. Knowing the history lets you recognise legacy code and strip it out safely.
| Era | Rule | What it looked like in code |
|---|---|---|
Legacy (before Kotlin 1.7.20) |
Objects shared between threads had to be frozen (deeply immutable). Mutating a frozen object threw |
|
Transition |
The native-mt build of kotlinx-coroutines (versions such as |
|
New memory manager |
Became the default in Kotlin 1.7.20; the legacy one was removed in later releases. Objects can be shared and mutated across threads without freezing. |
Standard |
See Kotlin docs — Kotlin/Native memory management and Kotlin docs — Migrate to the new Kotlin/Native memory manager.
What the new memory manager means for old code
-
freeze()andisFrozenare deprecated no-ops. Remove them. Code that carefully froze values before sending them to a worker still compiles during migration but contributes nothing. -
The
-native-mtcoroutines artifact is no longer needed. Use the plainkotlinx-coroutines-coreversion in your catalog. The Kotlin Gradle plugin no longer reads thememoryModelproperty. -
@SharedImmutableand@ThreadLocalon top-level properties exist to emulate the old rules; top-level properties are now shared normally.@ThreadLocalstill has a legitimate use: a value that must differ per thread. -
Hand-rolled "main thread only" wrappers and
Worker-based executors written to dodge freezing can almost always be replaced bywithContext(dispatcher). -
Third-party libraries pinned to a pre-1.7.20 version (for example
ktor-client1.x or oldStatelywrappers) must be upgraded; recent versions of Ktor, SQLDelight, Koin and Coil are compatible.
The new memory manager changes the safety model, not the correctness model. Data races are possible again, in
the same way as on the JVM: two threads can still read-modify-write a shared var with lost updates. What you
gained is that the runtime no longer rejects sharing outright; what you must still provide is synchronisation.
Atomics and Shared Mutable State
Prefer confining state to one coroutine or exposing it through a StateFlow; reach for an atomic only for tiny
counters or flags shared across threads without a coroutine owner. Kotlin’s standard library now has its own
multiplatform atomics in kotlin.concurrent.atomics: AtomicInt, AtomicLong, AtomicBoolean,
AtomicReference, and array variants. The API is Experimental (it requires
@OptIn(ExperimentalAtomicApi::class)), so check the API reference for its current status before relying on it in a
public library; it replaces the previous mix of the Native-only kotlin.native.concurrent.AtomicInt, the JVM-only
java.util.concurrent.atomic types, and the third-party atomicfu library for many uses.
// sharedLogic/src/commonMain/kotlin/com/example/trailmate/data/RequestCounter.kt
package com.example.trailmate.data
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.incrementAndFetch
/** Counts in-flight network requests so the UI can show a global spinner. */
@OptIn(ExperimentalAtomicApi::class)
class RequestCounter {
private val inFlight = AtomicInt(0)
fun begin(): Int = inFlight.incrementAndFetch()
fun end(): Int = inFlight.addAndFetch(-1)
fun current(): Int = inFlight.load()
}
| Need | Use | Why |
|---|---|---|
Observable UI state |
|
Safe, conflated, observable from Swift. |
A counter or flag read by many threads |
|
Lock-free, no scope required. |
Serialised access to a larger structure |
|
Suspends instead of blocking a thread. |
Single-threaded owner of mutable data |
|
Confinement removes the need for locks. |
A value that differs per thread |
|
Rarely needed in new code. |
Cancellation, Exceptions and Swift
Cancelling a Swift Task cancels the Kotlin coroutine collecting on its behalf (with SKIE or
KMP-NativeCoroutines).
Uncaught exceptions are different on Native than on the JVM. An exception that escapes a launch in a scope with
no CoroutineExceptionHandler goes to the platform’s unhandled-exception hook; on Kotlin/Native that terminates
the process by default. Install handlers in app-level scopes, and keep viewModelScope children wrapped in try
as shown in TrailListViewModel. See
Kotlin docs — Coroutine exceptions handling.
Testing Concurrent Code
Because every dispatcher is injectable, tests replace Dispatchers.Main with a StandardTestDispatcher using
Dispatchers.setMain, drive virtual time with runTest, and assert on flows with Turbine. The details,
including running common tests on iOS simulators, are on
Testing Shared Code.
Common Mistakes
| Mistake | Fix |
|---|---|
Using |
Add the artifact in |
Keeping |
Remove them; use the plain coroutines version. |
Catching |
Rethrow it first. |
Updating UI-bound state from |
Switch to |
Using |
Expose a |
Summary
-
kotlinx-coroutines-coreis a Stable multiplatform library: add it tocommonMainand write coroutine code once. -
Dispatchers.Mainis the main queue on iOS, the main looper on Android, the Swing event thread on desktop (withkotlinx-coroutines-swing) and the one browser thread on wasmJs;Dispatchers.IOexists in common code since kotlinx-coroutines 1.7. -
Own your coroutines with
viewModelScope; make suspend functions main-safe; rethrowCancellationException. -
Expose screen state as
StateFlow<UiState>and useupdate { }; model one-off events as state where you can. -
From Swift, collect flows through SKIE or KMP-NativeCoroutines (Swift export is Alpha) and keep UIKit work on the main thread.
-
Freezing,
native-mtand@SharedImmutablebelong to the old memory model and should be deleted. -
kotlin.concurrent.atomicsis Experimental; preferStateFlow,Mutexor confinement for most shared state. -
Test with injected dispatchers,
runTestand Turbine.