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

Dispatchers.Main

Main looper (needs kotlinx-coroutines-android).

The main queue (dispatch_get_main_queue), the same one UIKit uses.

Throws IllegalStateException unless kotlinx-coroutines-swing (or -javafx) is on the classpath.

The single browser thread; always available.

Dispatchers.Default

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 Main (no real parallelism).

Dispatchers.IO

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 Default.

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()
Dispatchers.Main.immediate
: 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.

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

Four rules make this ViewModel correct on every platform.

  • Launch only from a scope with an owner. viewModelScope is tied to the ViewModel; GlobalScope is 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; catching Exception blindly 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; value is always readable.

None; consumers wait for the first emission.

Replay

Exactly one (the latest value).

Configurable (replay), default zero.

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 extraBufferCapacity and onBufferOverflow.

Typical use

Screen state (TrailListUiState).

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 viewModelScope (Dispatchers.Main.immediate), or withContext(Dispatchers.Main) before touching UI-bound state.

Calling a UIKit API from iosMain

Call it from the main dispatcher; UIView and CALayer calls from Dispatchers.Default crash or misbehave.

Receiving a delegate callback (for example CLLocationManagerDelegate)

Callbacks arrive on the thread of the manager’s creation; bridge into a callbackFlow and flowOn explicitly.

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 InvalidMutabilityException.

.freeze(), isFrozen, @SharedImmutable, @ThreadLocal, AtomicReference for mutable shared state, DetachedObjectGraph.

Transition

The native-mt build of kotlinx-coroutines (versions such as 1.6.4-native-mt) allowed multithreaded dispatchers on Native while the old memory manager was still around.

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4-native-mt"), plus kotlin.native.binary.memoryModel=experimental.

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 kotlinx-coroutines-core with no special version; Dispatchers.Default and Dispatchers.IO use real thread pools.

What the new memory manager means for old code

  • freeze() and isFrozen are 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-mt coroutines artifact is no longer needed. Use the plain kotlinx-coroutines-core version in your catalog. The Kotlin Gradle plugin no longer reads the memoryModel property.

  • @SharedImmutable and @ThreadLocal on top-level properties exist to emulate the old rules; top-level properties are now shared normally. @ThreadLocal still 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 by withContext(dispatcher).

  • Third-party libraries pinned to a pre-1.7.20 version (for example ktor-client 1.x or old Stately wrappers) 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

MutableStateFlow and update { }

Safe, conflated, observable from Swift.

A counter or flag read by many threads

kotlin.concurrent.atomics

Lock-free, no scope required.

Serialised access to a larger structure

kotlinx.coroutines.sync.Mutex with withLock

Suspends instead of blocking a thread.

Single-threaded owner of mutable data

limitedParallelism(1) on a dispatcher, or one actor-style coroutine

Confinement removes the need for locks.

A value that differs per thread

@ThreadLocal (Native)

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 Dispatchers.Main on desktop without kotlinx-coroutines-swing.

Add the artifact in desktopMain or desktopApp.

Keeping freeze(), @SharedImmutable and -native-mt after migrating.

Remove them; use the plain coroutines version.

Catching Exception around a suspend call and swallowing CancellationException.

Rethrow it first.

Updating UI-bound state from Dispatchers.Default on iOS.

Switch to Dispatchers.Main first, or rely on viewModelScope.

Using runBlocking in shared code that runs on a UI thread.

Expose a suspend function or a Flow instead.

Summary

  • kotlinx-coroutines-core is a Stable multiplatform library: add it to commonMain and write coroutine code once.

  • Dispatchers.Main is the main queue on iOS, the main looper on Android, the Swing event thread on desktop (with kotlinx-coroutines-swing) and the one browser thread on wasmJs; Dispatchers.IO exists in common code since kotlinx-coroutines 1.7.

  • Own your coroutines with viewModelScope; make suspend functions main-safe; rethrow CancellationException.

  • Expose screen state as StateFlow<UiState> and use update { }; 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-mt and @SharedImmutable belong to the old memory model and should be deleted.

  • kotlin.concurrent.atomics is Experimental; prefer StateFlow, Mutex or confinement for most shared state.

  • Test with injected dispatchers, runTest and Turbine.