Android · Kotlin coroutines

Coroutines on Android: from suspend to structured concurrency

How suspend functions, scopes, dispatchers, jobs and flows fit together, and how to use them safely with the Android lifecycle.

Updated 2026-09-08Kotlin · Jetpack Compose
On this page

Coroutines are the way Android code waits without freezing: a network call, a database read or a two second delay is written as a straight line of code, and the thread underneath is free to do something else in the meantime. Getting them right means understanding five things that fit together like gears: suspending functions, scopes, contexts, jobs and flows. This article takes them in that order and ends with how each one meets the Android lifecycle.

The map

Read this first when short on time. Every branch is a section below.

Figure 1. The whole article on one page. Every branch is a section below; fold what you know, open what you do not.

What a coroutine is, and is not

A coroutine is a computation that can pause itself at a marked point, let go of its thread, and be resumed later, possibly on a different thread. It is not a thread. A thread is an operating system resource with a megabyte of stack; a coroutine is a small object on the heap that remembers where it was.

sequenceDiagram
  autonumber
  participant T as One thread
  participant A as Coroutine A
  participant B as Coroutine B
  participant N as Network
  T->>A: run until the first suspension
  A->>N: request, then suspend
  Note over T: thread is free, not blocked
  T->>B: run coroutine B meanwhile
  N-->>A: response ready
  Note over A: A is queued to resume
  T->>A: resume where it left off
Figure 2. One thread, two coroutines. Suspending hands the thread back; blocking would have held it for the whole wait.
ThreadCoroutine
What it isAn OS resource with its own stack, scheduled by the kernelAn object holding the local variables and the next step; scheduled by a dispatcher
CostAbout 1 MB of stack, expensive to create and to switch betweenA few hundred bytes, microseconds to create
WaitingBlocks: the thread sits idle until the result arrivesSuspends: the thread is released and reused
How manyHundreds before the system suffersHundreds of thousands on a small pool of threads
Where the code runsOn that thread, alwaysOn whatever thread the dispatcher provides at each resumption

The word to hold on to is suspend. Blocking code waits by occupying a thread. Suspending code waits by saving its state and stepping aside, which is why one main thread can run an entire app's worth of coroutines without ever freezing, as long as none of them blocks.

In the wild
  • Retrofit and Room expose suspend functions, so a network call or a query is one line that suspends instead of a callback or a background thread you manage yourself.
  • Jetpack Compose runs its recomposer on a coroutine dispatcher tied to the Choreographer; every animation in Compose is a coroutine.
  • Ktor, both client and server, is written entirely on coroutines, which is why one JVM can hold tens of thousands of open connections.

Suspending functions

A suspending function is one marked with suspend: it may pause at a suspension point and resume later without blocking. It can only be called from a coroutine or from another suspending function, because the caller must be able to be paused too.

suspend fun loadProfile(id: String): Profile {
    val user = api.getUser(id)            // suspension point: the Retrofit call suspends
    val posts = api.getPosts(id)          // runs only after the user arrived
    delay(200)                            // suspension point: no thread is held for 200 ms
    return Profile(user, posts)
}

Turning a callback into a suspend function

Most platform and SDK APIs are still callbacks. suspendCancellableCoroutine bridges them: it pauses the coroutine, hands you a continuation to resume with a value or an error, and lets you react if the coroutine is cancelled while waiting.

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? =
    suspendCancellableCoroutine { cont ->
        val task = lastLocation
        task.addOnSuccessListener { cont.resume(it) }
        task.addOnFailureListener { cont.resumeWithException(it) }
        cont.invokeOnCancellation { /* cancel the underlying request if the API allows it */ }
    }

// Main-safe repository function: callers never think about threads
class UserRepository(private val dao: UserDao, private val files: FileStore) {
    suspend fun exportUser(id: String): File = withContext(Dispatchers.IO) {
        val user = dao.get(id)               // Room is already main-safe, harmless here
        files.write("$id.json", user.toJson()) // blocking file write: this is why we are on IO
    }
}
Watch out

Resume a continuation exactly once. Resuming twice throws; never resuming leaks the coroutine forever. And suspendCoroutine without the "Cancellable" is almost never what you want on Android: it ignores cancellation, so a screen that closes keeps waiting for a callback that nobody needs.

Suspension point
A call where the coroutine may pause and give up its thread. Only suspend functions create them.
Continuation
The object that captures "what to do next" when a coroutine suspends. Resuming it continues the coroutine.
Main-safe
A suspend function that is safe to call from the main thread because it switches dispatchers internally for any blocking work.

Builders: launch, async and runBlocking

A coroutine builder starts a new coroutine. The three differ in what they return, whether they block the caller, and what happens to an exception thrown inside.

BuilderReturnsBlocks the caller?ExceptionsUse it for
launchJobNoThrown up the parent hierarchy the moment they happenFire-and-forget work: handle a click, log an event, start a collection
asyncDeferred<T>NoStored in the Deferred and rethrown by await(); a non-supervisor parent is still cancelled immediatelySeveral results computed in parallel and combined
runBlockingTYes, until the block and its children finishRethrown to the calling threadTests and main(); never on the Android main thread

Two more functions look like builders but are not: coroutineScope { } and withContext(...) { } are suspend functions that run their block in a child scope and return its result. They do not start concurrent work by themselves; they give launch and async a place to live.

// Parallel decomposition: two calls at once, results combined
suspend fun loadDashboard(id: String): Dashboard = coroutineScope {
    val user = async { api.getUser(id) }
    val orders = async { api.getOrders(id) }
    Dashboard(user.await(), orders.await())     // total time = the slower call, not the sum
}

// Fire and forget from a ViewModel
fun onRefreshClicked() {
    viewModelScope.launch { repository.refresh() }
}

// Many in parallel with a bound
suspend fun thumbnails(ids: List<String>): List<Bitmap> = coroutineScope {
    val gate = Semaphore(4)
    ids.map { id -> async { gate.withPermit { decoder.decode(id) } } }.awaitAll()
}
Watch out

GlobalScope.launch starts a coroutine that nothing owns: it survives the screen, the ViewModel and the process's idea of what is running, and its failures go straight to the uncaught handler. If a piece of work really must outlive the screen, give it a scope you control (an application-level scope, or WorkManager).

In the wild
  • Google's architecture samples use viewModelScope.launch for every user action and coroutineScope with async inside repositories to fan out.
  • kotlinx.coroutines tests use runBlocking's successor runTest, which skips delays; plain runBlocking in tests waits in real time.

CoroutineContext: what a coroutine carries with it

A CoroutineContext is an indexed set of elements that describe how a coroutine runs: which Job it belongs to, which dispatcher runs it, its name, and how uncaught exceptions are handled. Every coroutine has one, and a child's context is built from its parent's.

flowchart TD
  P["Parent scope context<br/>Job P · Dispatchers.Main · name = ui"]
  L["launch(Dispatchers.IO + CoroutineName(sync))"]
  C["Child context<br/>Job C, child of P · Dispatchers.IO · name = sync"]
  P --> L --> C
  C -. inherits what it did not override .-> P
Figure 3. Child context = parent context, plus whatever the builder was given, plus a brand new Job whose parent is the parent's Job.
ElementWhat it controlsDefault
JobLifecycle, cancellation, the parent-child treeA new child of the parent's Job
CoroutineDispatcherWhich thread or pool runs the coroutineInherited; Dispatchers.Default if there is no parent
CoroutineNameA label for debugging and thread dumpsNone
CoroutineExceptionHandlerLast resort for uncaught exceptions in a root coroutineNone: the exception reaches the thread's uncaught handler
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + CoroutineName("app"))

scope.launch(Dispatchers.IO + CoroutineName("sync")) {
    println(coroutineContext[CoroutineName])          // sync
    println(coroutineContext[CoroutineDispatcher])    // Dispatchers.IO
}

Elements combine with +; the right-hand side wins for the same key. The one element that is never simply inherited is the Job: a builder always creates a new one for the child and links it to the parent's, which is the mechanism behind everything in the structured concurrency section.

CoroutineScope
A holder for a context. Its Job defines how long everything launched in it lives. Builders are extensions on it.
coroutineContext
A property available inside any suspend function or coroutine that returns the current context.

Dispatchers and withContext

A dispatcher decides which thread runs a coroutine when it starts and every time it resumes. Android gives you three that matter and one to avoid, and withContext is how a coroutine moves between them for part of its work.

flowchart TD
  Q{What does this code do?} -- touches Views, Compose state, or must be on the UI thread --> M[Dispatchers.Main]
  Q -- waits on disk, network, a blocking SDK --> IO[Dispatchers.IO]
  Q -- burns CPU: parsing, sorting, image work --> D[Dispatchers.Default]
  Q -- just suspends: Retrofit, Room, delay --> S[stay where you are, it is already main-safe]
  M -- from a lifecycle scope --> MI[Main.immediate: no re-dispatch when already on main]
Figure 4. Pick by what the code does. Suspend calls that are already main-safe do not need a dispatcher at all.
DispatcherThreadsForNotes
Dispatchers.MainThe Android main threadUI updates, lifecycle work, anything touching ViewsAlways posts to the main Looper, even if you are already on it
Dispatchers.Main.immediateThe main threadThe same, without a needless hopRuns immediately when already on main; what viewModelScope and lifecycleScope use
Dispatchers.IOUp to 64 threads (or the core count if larger), created on demandBlocking calls: files, sockets, JDBC, old SDKsSized for waiting, not computing; a thread parked on I/O costs nothing
Dispatchers.DefaultAs many threads as CPU cores, at least twoCPU-bound work: JSON parsing, sorting, diffing, image processingMore threads than cores would only add context switching
Dispatchers.UnconfinedWhatever thread resumes itAlmost nothing in application codeStarts on the caller's thread, continues on whichever thread the last suspension completed on

withContext

withContext(dispatcher) { } suspends the coroutine, runs the block on the other dispatcher, and resumes on the original one with the block's result. It is the replacement for nested callbacks: read a file on IO, then update the UI on Main, written top to bottom.

viewModelScope.launch {                              // Main.immediate
    val report = withContext(Dispatchers.Default) {  // hop to a CPU thread
        buildReport(rawData)                         // heavy computation
    }                                                // back on Main
    _uiState.update { it.copy(report = report) }
}
Watch out

Two habits waste threads. Wrapping an already main-safe call such as a Retrofit suspend function in withContext(Dispatchers.IO) does nothing but add a hop. And running CPU work on Dispatchers.IO starves real I/O: 64 threads all parsing JSON is 64 threads fighting over eight cores while the file reads wait.

In the wild
  • Room runs suspend queries on its own executor and Retrofit on OkHttp's dispatcher, which is why neither needs withContext(IO) around it.
  • Compose uses AndroidUiDispatcher for recomposition, a Main-thread dispatcher that batches work per frame.
  • Hilt projects commonly inject dispatchers as qualified CoroutineDispatcher instances so tests can replace them.

Job: the handle and its lifecycle

A Job is the cancellable handle to a coroutine. It has a state machine, a parent, children, and callbacks. launch returns one; async returns a Deferred, which is a Job with a result.

stateDiagram-v2
  [*] --> New: start = LAZY
  [*] --> Active: launch or async
  New --> Active: start() or join()
  Active --> Completing: body finished, children still running
  Completing --> Completed: last child finished
  Active --> Cancelling: cancel() or a failure
  Completing --> Cancelling: a child failed
  Cancelling --> Cancelled: children cancelled, finally blocks ran
  Completed --> [*]
  Cancelled --> [*]
Figure 5. Job states. isActive is true only in Active and Completing; isCancelled is true from Cancelling onward.
val job = scope.launch { syncEverything() }
job.invokeOnCompletion { cause -> log("sync finished, cause = $cause") }  // null on success

// later, when the user leaves
job.cancelAndJoin()      // request cancellation, then wait until it has actually stopped

val deferred: Deferred<Int> = scope.async { compute() }
val value = deferred.await()   // suspends until the result exists, rethrows if it failed
Deferred
A Job that will produce a value. await() suspends until it does.
SupervisorJob
A Job whose children fail independently: a child's failure does not cancel the supervisor or its siblings.

Structured concurrency: coroutines have parents

Structured concurrency is the rule that every coroutine belongs to a scope, and a scope does not finish until every coroutine inside it has finished. Cancellation flows down the tree, failure flows up, and nothing runs that nobody is responsible for.

flowchart TD
  S["viewModelScope<br/>SupervisorJob"] --> A["launch: load profile"]
  S --> B["launch: sync settings"]
  A --> A1["async: user"]
  A --> A2["async: posts"]
  A2 -- throws --> A
  A -- cancels its other child --> A1
  A -. reports the failure to .-> S
  S -. supervisor: sibling B keeps running .-> B
Figure 6. A failure in posts cancels its sibling and fails the load profile parent. The supervisor scope above stops the spread there.
  1. A scope owns its children. scope.cancel() cancels every coroutine launched in it, recursively. This is why cancelling viewModelScope in onCleared stops all the ViewModel's work at once.
  2. A parent waits. coroutineScope { launch { }; launch { } } returns only after both launches complete. No coroutine is ever lost.
  3. A failure climbs. An exception in a child cancels the parent, which cancels the other children, and so on up, until a supervisor or a handler stops it.

coroutineScope versus supervisorScope

coroutineScope { }supervisorScope { }
One child failsSiblings are cancelled, the scope rethrowsSiblings keep running, the failure stays with that child
Use forWork that only makes sense as a whole: fetch user and posts, then combineIndependent work: several widgets loading on one screen
Who sees the exceptionThe caller of coroutineScopeA CoroutineExceptionHandler on the child, or the app crashes
// Building your own scope for something with an explicit lifetime
class SyncEngine(dispatcher: CoroutineDispatcher = Dispatchers.Default) {
    private val scope = CoroutineScope(SupervisorJob() + dispatcher + CoroutineName("sync"))
    fun start() { scope.launch { runForever() } }
    fun stop() { scope.cancel() }            // everything launched above stops
}
Watch out

viewModelScope.launch(SupervisorJob()) { } does not make a child "supervised". It gives the coroutine a parent that is not the scope's Job, so the scope can no longer cancel it. A SupervisorJob only supervises the coroutines launched directly under it, which is why it belongs in the scope's constructor, or you use supervisorScope { }.

In the wild
  • viewModelScope and lifecycleScope are both SupervisorJob() + Dispatchers.Main.immediate: one failing load must not cancel the whole screen.
  • Ktor's request pipeline runs each request in its own child scope, so an aborted connection cancels exactly that request's work.

Cancellation is cooperative

Cancelling a coroutine does not stop it; it asks it to stop. The Job moves to Cancelling, and the coroutine actually ends the next time it reaches a suspension point or checks its state. Code that never suspends and never checks will run to the end regardless.

sequenceDiagram
  autonumber
  participant O as Owner
  participant J as Job
  participant C as Coroutine body
  participant R as Resource
  O->>J: cancel()
  J->>J: state = Cancelling, children cancelled
  Note over C: still running a loop, has not noticed
  C->>C: reaches delay(), ensureActive() or yield()
  C--xC: CancellationException thrown
  C->>R: finally: close()
  C-->>J: finished
  J->>J: state = Cancelled
  J-->>O: join() returns
Figure 7. Cancellation is a request that the coroutine honours at its next suspension point. finally still runs.
// A CPU loop must check for itself
suspend fun crunch(items: List<Item>) = withContext(Dispatchers.Default) {
    for (item in items) {
        ensureActive()          // throws CancellationException if cancelled
        process(item)
    }
}

// Cleanup that must complete even after cancellation
suspend fun saveDraft(draft: Draft) {
    try {
        editor.awaitChanges()
    } finally {
        withContext(NonCancellable) {   // suspend calls here still run
            store.write(draft)
        }
    }
}

// Timeouts are cancellation too
val result = withTimeoutOrNull(3_000) { api.search(query) }   // null if it took too long
Watch out

runCatching { } catches CancellationException and turns it into a failed Result, so a cancelled coroutine looks like a network error and carries on. Either check if (e is CancellationException) throw e in every catch, or catch specific exceptions only.

In the wild
  • Retrofit and OkHttp cancel the HTTP call when the coroutine is cancelled, through invokeOnCancellation in their suspend adapter.
  • Compose's LaunchedEffect cancels its coroutine when the composable leaves the composition or its key changes, which is cancellation doing UI cleanup for you.

Exceptions: where they go

An exception inside a coroutine follows the Job tree. Where it ends up depends on the builder that started the coroutine and on whether a supervisor sits between it and the root.

flowchart TD
  E[exception thrown in a coroutine] --> B{started with?}
  B -- launch --> P{parent Job?}
  B -- async --> D["stored in the Deferred,<br/>rethrown by await()"]
  D --> P
  P -- regular Job --> UP[parent is cancelled, siblings cancelled, climb one level]
  UP --> P
  P -- SupervisorJob or supervisorScope --> H{CoroutineExceptionHandler in context?}
  P -- no parent, root coroutine --> H
  H -- yes --> HH[handler runs, coroutine is done]
  H -- no --> X[thread's uncaught handler: the app crashes]
Figure 8. The climb stops at a supervisor or the root; only there does a CoroutineExceptionHandler get a say.
// Handler for the whole scope: logs whatever nothing else caught
val handler = CoroutineExceptionHandler { _, e -> crashReporter.record(e) }
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)

// Independent loads on one screen: one failing widget must not blank the others
suspend fun loadHome(): HomeState = supervisorScope {
    val news = async { newsRepo.latest() }
    val weather = async { weatherRepo.today() }
    HomeState(news = news.orNull(), weather = weather.orNull())
}

// await, but treat a failure as "no data"; cancellation still propagates
private suspend fun <T> Deferred<T>.orNull(): T? =
    try { await() } catch (e: CancellationException) { throw e } catch (e: Exception) { null }
Watch out

On Android an exception that reaches the root of viewModelScope with no handler crashes the app, exactly like an uncaught exception on any thread. The fix is not a global handler that hides bugs; it is catching expected failures (network, parsing) at the call site and turning them into UI state.

Flow: cold streams

A Flow is a sequence of values produced over time. It is cold: the code inside the flow builder does nothing until someone collects, and it runs again from the start for every collector. Think of it as a suspend function that returns many values instead of one.

flowchart TD
  P["flow { emit(...) }<br/>runs on IO because of flowOn below"] --> M["map { parse(it) }<br/>also upstream of flowOn: IO"]
  M --> F["flowOn(Dispatchers.IO)"]
  F --> C["catch { emit(fallback) }<br/>collector's context: Main"]
  C --> T["collect { render(it) }<br/>Main"]
Figure 9. flowOn changes the dispatcher for everything above it; the collector always runs in its own context.
// A cold flow: nothing happens until collect
fun ticks(): Flow<Int> = flow {
    var n = 0
    while (true) { emit(n++); delay(1_000) }
}

// Callback API as a flow: closes the listener when the collector goes away
fun locations(client: FusedLocationProviderClient): Flow<Location> = callbackFlow {
    val cb = object : LocationCallback() {
        override fun onLocationResult(r: LocationResult) { r.lastLocation?.let { trySend(it) } }
    }
    client.requestLocationUpdates(request, cb, Looper.getMainLooper())
    awaitClose { client.removeLocationUpdates(cb) }
}

// Operators compose lazily; the pipeline runs per collector
val screen: Flow<ScreenState> = combine(dao.observeUser(id), settings.theme) { user, theme ->
    ScreenState(user, theme)
}
    .flowOn(Dispatchers.Default)        // combine's work runs off the main thread
    .catch { emit(ScreenState.error(it)) }
OperatorDoesReach for it when
map, filter, transformReshape each valueAlways; they are lazy and cheap
flowOnSets the dispatcher for everything upstreamThe producer or an operator does heavy work
catch, retry, retryWhenHandle upstream failures, optionally resubscribeNetwork-backed flows
bufferLets the producer run ahead of a slow collectorProducer is fast, collector is slow
conflateKeeps only the latest value when the collector lagsUI state where intermediate values do not matter
collectLatest, flatMapLatestCancels the previous block or inner flow when a new value arrivesSearch-as-you-type, anything where only the newest input counts
combine, zipMerge several flows: latest of each, or pairwiseScreen state built from several sources
debounce, distinctUntilChangedQuiet a noisy sourceText input, sensors
first, toList, collectTerminal operators that start the flowOne value, all values, or a running collection
Watch out

A flow must not emit from a different coroutine than the builder's (context preservation), and it must not catch exceptions around its own emit (exception transparency); the collector's catch operator is the sanctioned place. Both rules exist so that flowOn and catch can reason about the pipeline.

In the wild
  • Room returns Flow<List<T>> from observable queries and re-emits when the table changes; DataStore exposes preferences the same way.
  • Paging 3 delivers pages as Flow<PagingData>; Compose turns any flow into state with collectAsStateWithLifecycle.
  • Search screens everywhere are debounce(300), distinctUntilChanged(), flatMapLatest { search(it) }.

StateFlow and SharedFlow: hot streams

A hot flow exists and holds values whether or not anyone is collecting. StateFlow holds one current value and is the standard way to expose UI state; SharedFlow broadcasts a stream of values to every collector and is the tool for events.

flowchart TD
  VM["ViewModel<br/>_state.update { ... }"] --> SF["MutableStateFlow<br/>value = latest, replay 1"]
  SF --> C1["collector 1: Compose screen"]
  SF --> C2["collector 2: analytics"]
  L["late collector after a rotation"] -. receives the current value at once .-> SF
Figure 10. A StateFlow always has a value; whoever subscribes gets it immediately and then every distinct change.
FlowStateFlowSharedFlowChannel
TemperatureCold: runs per collectorHotHotHot
HoldsNothingExactly one current value, needs an initial oneA replay cache of N values (default 0)A buffer of unconsumed values
Duplicate valuesDeliveredSkipped when equalsDeliveredDelivered
CollectorsEach gets its own runMany, all see the same valueMany, all see every emission after they joinedEach value goes to one receiver
No collector presentNothing runsValue is keptEmission is dropped unless replay or buffer keeps itValue waits in the buffer
Use forData sources, pipelinesUI stateBroadcast events with several listenersOne-shot events to a single consumer, work queues
class ProfileViewModel(private val repo: UserRepository) : ViewModel() {

    // State: private mutable, public read-only, atomic updates
    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()

    fun onFollow() { _uiState.update { it.copy(following = true) } }

    // A cold repository flow turned into UI state, alive only while the screen is
    val feed: StateFlow<List<Post>> = repo.observeFeed()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    // Events: things that happen once, such as navigation or a snackbar
    private val _events = MutableSharedFlow<ProfileEvent>(extraBufferCapacity = 8)
    val events: SharedFlow<ProfileEvent> = _events.asSharedFlow()
    fun onShareClicked() { _events.tryEmit(ProfileEvent.OpenShareSheet) }
}
Watch out

A MutableSharedFlow with default settings drops an emission when there is no subscriber, and tryEmit returns false when the buffer is full. For events that must not be lost while the UI is in the background, use a Channel exposed as receiveAsFlow(), or model the event as state that the UI acknowledges.

In the wild
  • Google's guidance is StateFlow for UI state, WhileSubscribed(5_000) for stateIn, and events modelled as state where possible.
  • Now in Android (Google's sample app) builds every screen state with combine over repository flows and stateIn in the ViewModel.

Channels: point-to-point pipes

A Channel connects a sender and a receiver with a buffer in between. send suspends when the buffer is full and receive suspends when it is empty, which gives backpressure for free. Each value is delivered to exactly one receiver.

flowchart LR
  P[producer coroutine] -- send, suspends when full --> CH[["Channel<br/>buffer of 64"]]
  CH -- receive, suspends when empty --> W1[worker 1]
  CH --> W2[worker 2]
Figure 11. Values queue in the channel; several receivers share the work, each value going to one of them.
CapacityBehaviour
RENDEZVOUS (0, the default)Sender waits until a receiver takes the value; a handshake
BUFFERED (64)Sender runs ahead up to the buffer, then suspends
CONFLATEDOnly the latest value is kept; older unconsumed ones are dropped
UNLIMITEDNever suspends the sender; memory is the limit
// One-shot UI events that must not be lost or duplicated
private val _navigation = Channel<NavCommand>(Channel.BUFFERED)
val navigation: Flow<NavCommand> = _navigation.receiveAsFlow()

fun onSaved() { viewModelScope.launch { _navigation.send(NavCommand.Back) } }

// A pipeline with the producer builder
fun CoroutineScope.thumbnails(files: List<File>): ReceiveChannel<Bitmap> = produce(capacity = 8) {
    for (file in files) send(decode(file))   // closes the channel when the block ends
}
In the wild
  • Flow's buffer and callbackFlow are channels underneath; callbackFlow's trySend is a channel send.
  • Ktor's WebSocket sessions expose incoming and outgoing frames as channels.
  • The actor pattern, a coroutine that owns some state and reads commands from a channel, is how many apps serialise access to a single resource without locks.

Android lifecycle integration

Android gives every lifecycle-bound object a scope that is cancelled when the object goes away, and a way to pause collection while the UI is not visible. Using them is what keeps coroutines from leaking screens and from doing work nobody can see.

sequenceDiagram
  autonumber
  participant L as Lifecycle
  participant F as Fragment
  participant S as StateFlow
  F->>L: lifecycleScope.launch { repeatOnLifecycle(STARTED) { ... } }
  L->>F: STARTED: run the block, start collecting
  F->>S: collect
  S-->>F: state updates render
  L->>F: STOPPED: block cancelled, collection stops
  Note over S: upstream can stop too after WhileSubscribed timeout
  L->>F: STARTED again: block runs again, collection restarts
  L->>F: DESTROYED: lifecycleScope cancelled
Figure 12. repeatOnLifecycle restarts its block every time the lifecycle reaches the state and cancels it every time it drops below.
ToolWhat it isCancelled when
viewModelScopeSupervisorJob() + Dispatchers.Main.immediate on every ViewModelonCleared(): the screen is finished, not on rotation
lifecycleScopeThe same, on any LifecycleOwner (Activity, Fragment, viewLifecycleOwner)The lifecycle is destroyed
repeatOnLifecycle(state)A suspend function that runs its block while the lifecycle is at least stateEach time the lifecycle drops below; restarted when it returns
flowWithLifecycle(lifecycle, state)The same idea as an operator on one flowSame
collectAsStateWithLifecycle()Compose: collect a flow into State, paused below STARTEDThe composable leaves the composition
LaunchedEffect(key)Compose: a coroutine tied to the compositionThe composable leaves, or the key changes
CoroutineWorkerWorkManager job with a suspend doWork()The work is stopped by the system or by you
class UserProfileViewModel(
    private val userRepository: UserRepository,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO   // injected so tests can replace it
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun loadUserData(userId: String) {
        viewModelScope.launch {                       // Main.immediate, cancelled in onCleared()
            _uiState.value = UiState.Loading
            try {
                val user = withContext(ioDispatcher) { userRepository.fetchUser(userId) }
                _uiState.value = UiState.Success(user)
            } catch (e: CancellationException) {
                throw e                                // never swallow cancellation
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.localizedMessage ?: "Unknown error")
            }
        }
    }
}

// Fragment: collect only while visible, with the view's lifecycle
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}

// Compose: one line, same behaviour
@Composable
fun ProfileScreen(viewModel: UserProfileViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    LaunchedEffect(Unit) { viewModel.loadUserData("42") }   // runs once per composition
    ProfileContent(state)
}
In the wild
  • lifecycle-runtime-compose ships collectAsStateWithLifecycle; Google's Compose guidance prefers it over plain collectAsState for any flow backed by real work.
  • WorkManager's CoroutineWorker runs doWork() on Dispatchers.Default and cancels the coroutine when the constraints stop being met.
  • Hilt's @HiltViewModel plus viewModelScope is the default shape of an Android screen today.

Testing coroutines

Coroutine tests replace real time and real threads with controlled ones. runTest gives you a scope whose delay is virtual, and a test dispatcher decides whether launched coroutines run eagerly or wait for you to advance them.

class MainDispatcherRule(val dispatcher: TestDispatcher = StandardTestDispatcher()) : TestWatcher() {
    override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
    override fun finished(description: Description) = Dispatchers.resetMain()
}

class UserProfileViewModelTest {
    @get:Rule val mainRule = MainDispatcherRule()

    @Test
    fun `loads user into success state`() = runTest {
        val repo = FakeUserRepository(user = User("42", "Sujith"))
        val vm = UserProfileViewModel(repo, ioDispatcher = mainRule.dispatcher)

        vm.loadUserData("42")
        assertEquals(UiState.Loading, vm.uiState.value)   // launch has not run yet: standard dispatcher queues it

        advanceUntilIdle()                                 // run everything that is queued
        assertEquals(UiState.Success(User("42", "Sujith")), vm.uiState.value)
    }

    @Test
    fun `emits feed items`() = runTest {
        val vm = ProfileViewModel(FakeUserRepository(feed = listOf(post)))
        vm.feed.test {                                     // Turbine
            assertEquals(emptyList(), awaitItem())
            assertEquals(listOf(post), awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }
}
In the wild
  • Turbine (Cash App) is the standard library for asserting on flows one item at a time.
  • kotlinx-coroutines-test replaced the older runBlockingTest with runTest in 1.6; virtual time means a test with a thirty second delay finishes instantly.

Under the hood: continuations and state machines

The compiler turns every suspend function into a state machine and adds one hidden parameter, the continuation. A suspension point saves the local variables into that object, returns a marker, and later the runtime calls the function again with the same object to continue from the saved state.

flowchart TD
  S0["label 0: call api.getUser(), pass this continuation"] --> Q0{did it suspend?}
  Q0 -- yes --> R0["return COROUTINE_SUSPENDED, thread is free"]
  R0 -. later: resumeWith(user) via the dispatcher .-> S1
  Q0 -- no, value ready --> S1["label 1: store user, call api.getPosts()"]
  S1 --> Q1{did it suspend?}
  Q1 -- yes --> R1["return COROUTINE_SUSPENDED"]
  R1 -. resumeWith(posts) .-> S2
  Q1 -- no --> S2["label 2: return Profile(user, posts)"]
Figure 13. One suspend function with two suspension points becomes one function with three entry labels and a continuation that remembers which one is next.
// What the compiler makes of loadProfile, in spirit
fun loadProfile(id: String, cont: Continuation<Profile>): Any? {
    val sm = cont as? LoadProfileSM ?: LoadProfileSM(cont)   // holds label, user, posts
    when (sm.label) {
        0 -> { sm.label = 1; val r = api.getUser(id, sm); if (r == COROUTINE_SUSPENDED) return r; sm.user = r as User }
        1 -> { sm.user = sm.result as User }
    }
    // ... same shape for getPosts, then
    return Profile(sm.user, sm.posts)
}
Continuation-passing style
The transformation that turns "return a value" into "call this continuation with the value", so that returning can be delayed.
COROUTINE_SUSPENDED
The marker a suspend function returns instead of a value when it has actually suspended.
ContinuationInterceptor
The context element that gets to wrap continuations; dispatchers are its only common implementation.

Mistakes that keep coming back

Watch out
  • GlobalScope for anything: nothing cancels it, nothing reports it.
  • Catching Exception or using runCatching around suspend calls without rethrowing CancellationException.
  • Blocking inside a coroutine: Thread.sleep, a synchronous HTTP client, runBlocking on the main thread. The dispatcher's thread is held and the app freezes or starves.
  • CPU work on Dispatchers.IO, or withContext(IO) around calls that are already main-safe.
  • Collecting in onCreate with lifecycleScope.launch { flow.collect }: keeps running in the background. Use repeatOnLifecycle or collectAsStateWithLifecycle.
  • async whose result is never awaited in a regular scope: the failure still cancels the scope, silently.
  • Exposing MutableStateFlow from a ViewModel; expose StateFlow through asStateFlow().
  • SharingStarted.Eagerly for a stateIn that watches a database: the upstream never stops. Use WhileSubscribed(5_000).
  • Passing a SupervisorJob() to launch: breaks the parent link instead of supervising.
  • Loading in a ViewModel's init without an idempotency guard, then wondering why a shared ViewModel loaded twice.
  • Resuming a continuation twice in a suspendCancellableCoroutine bridge when a callback fires more than once.

Recap

  • A coroutine is a suspendable computation, not a thread. Suspending releases the thread; blocking holds it.
  • suspend functions pause at suspension points and are sequential unless a builder introduces concurrency. Make them main-safe by switching dispatchers inside.
  • launch returns a Job and throws immediately; async returns a Deferred and rethrows at await but still cancels a regular parent; runBlocking is for tests and main().
  • A CoroutineContext is Job + dispatcher + name + exception handler. A child inherits its parent's context and gets a new Job under the parent's.
  • Main for UI (immediate from lifecycle scopes), IO for blocking calls, Default for CPU. withContext switches and returns; IO and Default share threads.
  • Jobs go New → Active → Completing → Completed, or → Cancelling → Cancelled. A parent completes only after its children.
  • Structured concurrency: cancellation flows down, failure flows up, a scope waits for its children. coroutineScope fails together; supervisorScope and SupervisorJob isolate direct children.
  • Cancellation is cooperative: honoured at suspension points or ensureActive(); never swallow CancellationException; use NonCancellable for cleanup that suspends; timeouts are cancellation.
  • Exceptions climb the Job tree; CoroutineExceptionHandler only counts at the root or under a supervisor; catch expected failures at the call site.
  • Flow is cold and runs per collector; flowOn moves upstream work; catch, buffer, conflate, collectLatest, combine shape the pipeline; callbackFlow bridges listeners.
  • StateFlow holds UI state (initial value, conflated, equality-skipped); SharedFlow broadcasts events; Channels deliver each value once with backpressure; stateIn with WhileSubscribed(5_000) shares a cold flow safely.
  • viewModelScope and lifecycleScope are supervisor scopes on Main.immediate; collect with repeatOnLifecycle(STARTED) or collectAsStateWithLifecycle; test with runTest, a Main dispatcher rule and injected dispatchers.

Questions

Try answering each one out loud before opening it. Lead with the one-line answer, then the reasoning, then one detail that shows you have used it.

What is a coroutine, and how is it different from a thread?

A coroutine is a computation that can suspend at marked points and resume later, possibly on another thread; a thread is an OS resource that a coroutine runs on, and one thread can run many coroutines because a suspended coroutine holds no thread at all.

  • Threads cost about a megabyte of stack and a kernel context switch; coroutines are heap objects that cost a few hundred bytes.
  • Blocking holds the thread; suspending releases it.
  • Which thread a coroutine resumes on is decided by its dispatcher.

An Android app's entire UI logic runs as coroutines on one main thread, and nothing freezes as long as none of them blocks.

What does the suspend keyword actually do?

It tells the compiler to turn the function into a state machine with a hidden continuation parameter, so the function can return early with a marker when it suspends and be re-entered later at the saved state.

  • A suspend function can only be called where a continuation is available: another suspend function or a coroutine.
  • Calls between suspension points run normally; nothing is asynchronous by itself.
  • Two suspend calls in sequence run sequentially; parallelism needs async.

Decompiled, suspend fun f(): T becomes fun f(cont: Continuation<T>): Any? returning either a value or COROUTINE_SUSPENDED.

What happens at a suspension point?

The function stores its locals and its position in the continuation, returns COROUTINE_SUSPENDED up the call chain so the thread is released, and later something calls resumeWith on the continuation, which the dispatcher schedules onto the right thread to continue from the saved label.

  • Only calls to suspend functions can be suspension points.
  • If the value is already available, no suspension happens and execution continues in place.
  • Resumption goes through the dispatcher, which is a ContinuationInterceptor in the context.

This is why a callback fired on a background thread can resume a coroutine on Main: the interceptor re-dispatches the resume.

Compare launch, async and runBlocking.

launch starts fire-and-forget work and returns a Job, throwing failures up the hierarchy immediately; async returns a Deferred whose value and exception surface at await; runBlocking blocks the calling thread until the coroutine and its children finish and belongs only in tests and main().

  • An async failure still cancels a non-supervisor parent right away; only the rethrow waits for await.
  • coroutineScope and withContext are scoping suspend functions, not builders.
  • Never call runBlocking on the Android main thread: it is a guaranteed freeze.

Parallel decomposition is the textbook async use: two calls, awaitAll, total time equal to the slower one.

What is a CoroutineContext, and how is a child's context built?

An indexed set of elements (Job, dispatcher, name, exception handler) combined with +; a child's context is the parent's context, overridden by whatever the builder was given, plus a new Job whose parent is the parent's Job.

  • Same-key elements on the right win when combining.
  • The Job is the one element never inherited as is; the new child Job is what makes structured concurrency work.
  • coroutineContext is readable inside any suspend function.

launch(Dispatchers.IO + CoroutineName("sync")) keeps the parent's handler and Job link but runs on IO under a new name.

How do you choose between Dispatchers.Main, IO and Default?

Main for anything touching the UI, IO for blocking calls that wait on disk or network, Default for CPU-bound work; and no dispatcher at all for calls that are already main-safe such as Retrofit and Room.

  • IO is sized for waiting (64 threads or more); Default is sized for computing (one per core).
  • Since 1.6 they share a pool, so switching between them is cheap.
  • limitedParallelism(n) carves a bounded slice without a new pool.

CPU work on IO is the common mistake: 64 threads parsing JSON starve the file reads the dispatcher exists for.

What is Dispatchers.Main.immediate, and why do viewModelScope and lifecycleScope use it?

It is the Main dispatcher that runs the coroutine immediately when the caller is already on the main thread instead of posting to the message queue; lifecycle scopes use it so that a launch from a click handler updates state in the same frame.

  • Plain Dispatchers.Main always posts, which costs a frame and can reorder work.
  • When called from a background thread, immediate behaves like Main.
  • Both scopes combine it with a SupervisorJob.

This is also why the first _uiState.value = Loading in a launch is visible before the function returns.

What is the difference between withContext and launch?

withContext is a suspend function that runs its block on another dispatcher and returns the result to the caller, sequentially; launch starts a new concurrent coroutine and returns a Job without waiting.

  • withContext is the tool for "do this part on IO, then continue here".
  • launch is the tool for "start this and move on".
  • withContext with the same dispatcher is nearly free; it still creates a child scope.

Replacing withContext(IO) with launch(IO) is a classic bug: the caller continues before the work is done.

Describe the lifecycle of a Job.

New (only if lazy), then Active, then Completing while the body has finished but children still run, then Completed; or from Active or Completing to Cancelling on cancel or failure, then Cancelled once children and finally blocks are done.

  • isActive is true in Active and Completing; isCancelled from Cancelling on.
  • join waits without throwing; await on a Deferred rethrows.
  • invokeOnCompletion gets the cause, null on success.

Completing is the state that shows a parent never finishes before its children.

What does structured concurrency guarantee?

That every coroutine has a parent scope, a scope does not complete until all its children have, cancelling a scope cancels everything inside it, and a child's failure is reported upward; no work is ever lost or orphaned.

  • It is implemented by the parent-child links between Jobs.
  • coroutineScope { } gives a suspend function a scope that waits for its own launches.
  • GlobalScope is the one escape hatch, and it breaks every guarantee.

Cancelling viewModelScope in onCleared stopping every load on the screen is the guarantee in action.

coroutineScope or supervisorScope: when do you use which?

coroutineScope when the children only make sense together, so one failure should cancel the rest and be reported to the caller; supervisorScope when the children are independent and each should fail alone.

  • In coroutineScope, the exception is rethrown from the scope call.
  • In supervisorScope, a failed launch child needs a handler or it crashes; a failed async child surfaces at await.
  • Both wait for all children before returning.

A dashboard with five independent widgets is supervisorScope; fetching a user and their orders to build one object is coroutineScope.

Where does SupervisorJob work, and where does it do nothing?

It works as the Job of a scope, supervising the coroutines launched directly in that scope; passed as a parameter to launch it does nothing useful and actually detaches the coroutine from the scope's Job.

  • CoroutineScope(SupervisorJob() + dispatcher) is the right shape.
  • scope.launch(SupervisorJob()) { } makes a coroutine the scope can no longer cancel.
  • Supervision applies only to direct children; grandchildren still fail their own parents normally.

viewModelScope is built exactly this way.

How does cancellation work, and why is it called cooperative?

Cancelling a Job sets it to Cancelling and cancels its children, but the coroutine only stops when it next reaches a suspension point or checks its own state, which is why code must cooperate by suspending or checking.

  • All kotlinx suspend functions check; a CPU loop needs ensureActive(), isActive or yield().
  • Cancellation is delivered as a CancellationException so finally blocks run.
  • A cancelled coroutine cannot suspend again except inside withContext(NonCancellable).

A loop that parses a big file without ever suspending will finish the file even after the user left the screen.

Why must you never swallow CancellationException?

Because it is the signal that carries cancellation; a coroutine that catches it and continues is a zombie that keeps running after its scope was cancelled, holding resources and updating dead UI.

  • catch (e: Exception) and runCatching both catch it.
  • Rethrow it, or catch only the specific exceptions you expect.
  • Timeouts use a subclass, so the same rule applies inside withTimeout blocks.

The usual fix is a two-line guard: if (e is CancellationException) throw e.

How do you run cleanup that itself needs to suspend after cancellation?

In a finally block wrapped in withContext(NonCancellable), because a cancelled coroutine throws on any further suspension unless it is inside a NonCancellable context.

  • Non-suspending cleanup (closing a stream) works in a plain finally.
  • Keep NonCancellable blocks short; nothing can stop them.
  • use { } covers the common resource case.

Writing a draft to disk when the editor screen is cancelled is the typical example.

Trace an exception thrown in a launch child versus an async child.

In a launch child the exception fails the child, cancels its siblings, fails the parent and climbs until a supervisor or the root, where a CoroutineExceptionHandler or the uncaught handler gets it; in an async child the same cancellation of a regular parent happens immediately, but the exception itself is stored and rethrown by await.

  • Under a supervisor, a failing launch child needs a handler in its context.
  • A root async never uses the handler; it waits for await.
  • A try/catch inside the coroutine prevents the failure from ever reaching the Job.

The surprise for most people is that an un-awaited async in coroutineScope still takes the scope down.

Where can a CoroutineExceptionHandler be installed so that it works?

Only where the exception stops climbing: in the context of a root coroutine's scope, or on a direct child of a supervisor; on a nested child of a regular Job it is ignored because the exception is passed to the parent instead.

  • It is a last resort for logging, not a way to recover.
  • It never applies to async.
  • On Android, an unhandled root exception crashes the app.

Putting it in the application scope's constructor is the standard placement.

Cold flow versus hot flow: what is the difference?

A cold flow's producer code runs from the start for every collector and does nothing without one; a hot flow exists and emits regardless of collectors, and collectors share what it holds.

  • flow { }, Room queries and callbackFlow are cold.
  • StateFlow, SharedFlow and channels are hot.
  • stateIn and shareIn convert cold to hot inside a scope.

Two collectors on a cold Retrofit-backed flow make two network calls; two on a stateIn of it make one.

StateFlow versus SharedFlow versus LiveData.

StateFlow holds one current value, requires an initial value and skips equal updates, so it models state; SharedFlow broadcasts every emission with a configurable replay and models events; LiveData is the older lifecycle-aware holder that StateFlow with repeatOnLifecycle replaces.

  • StateFlow is a SharedFlow with replay 1, conflation and equality dedupe.
  • LiveData is main-thread only with a handful of transformations; flows compose with the full operator set.
  • A SharedFlow with no subscriber drops emissions unless it replays or buffers.

Google's guidance is StateFlow for state, with LiveData kept only for existing code.

What does stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initial) do, and why 5000?

It turns a cold flow into a StateFlow that starts collecting the upstream when the first subscriber appears and stops five seconds after the last one leaves; five seconds outlasts a configuration change but not a trip to the background.

  • Eagerly would start at once and never stop; Lazily starts on first subscriber and never stops.
  • The initial value is served until the upstream produces one.
  • Rotation recreates the UI within the window, so the database query is not rerun.

Five seconds is the value Google recommends: comfortably longer than a configuration change, much shorter than a real stay in the background.

What does flowOn change, and what is context preservation?

flowOn changes the dispatcher for everything upstream of it and nothing downstream; context preservation is the rule that a flow's emissions happen in the collector's context unless flowOn explicitly moves the upstream, so a flow may not emit from an arbitrary coroutine.

  • Several flowOn calls each apply to their own upstream segment.
  • flowOn inserts a buffer, so the producer can run ahead.
  • callbackFlow and channelFlow exist for producers that emit from callbacks on other threads.

Room's flows already run their queries off the main thread, so they rarely need flowOn.

Explain buffer, conflate and collectLatest.

All three handle a producer that is faster than its collector: buffer queues values so the producer runs ahead, conflate keeps only the newest value and drops the rest, and collectLatest cancels the collector's block for the previous value when a new one arrives.

  • Default flow behaviour is fully sequential: emit waits for collect to finish.
  • conflate suits UI state; intermediate frames do not matter.
  • collectLatest and flatMapLatest suit search-as-you-type.

StateFlow is conflated by design, which is why a burst of updates renders only the last one.

How do you deliver one-shot events such as navigation from a ViewModel?

Preferably model them as state that the UI acknowledges; otherwise use a Channel exposed as receiveAsFlow(), which buffers events while nobody collects and delivers each exactly once, or a SharedFlow with a buffer when several listeners must see the event.

  • A StateFlow would replay the event on rotation and navigate twice.
  • A default SharedFlow drops the event when the UI is in the background.
  • Collect events with repeatOnLifecycle(STARTED) so they are handled only while visible.

Google's current guidance leans toward the state approach, with the Channel pattern as the pragmatic alternative.

Channel versus SharedFlow.

A Channel delivers each value to exactly one receiver and suspends the sender when its buffer is full, giving backpressure; a SharedFlow broadcasts each value to all current collectors and, by default, drops it when there are none.

  • Channels are for work distribution and single-consumer events.
  • SharedFlow is for fan-out.
  • Both are hot; both are built on the same internals.

produce and receiveAsFlow are the two builders most Android code touches.

What is viewModelScope made of, and when is it cancelled?

A CoroutineScope with SupervisorJob() + Dispatchers.Main.immediate, created lazily on first access and cancelled in onCleared(), which happens when the owner is finished for good, not on rotation.

  • Supervisor: one failed load does not cancel the others.
  • Main.immediate: state updates from the main thread land in the same frame.
  • Work that must survive the screen needs another scope or WorkManager.

lifecycleScope is the same recipe cancelled at DESTROYED.

Why repeatOnLifecycle instead of collecting directly in lifecycleScope?

Because lifecycleScope is cancelled only on destroy, a direct collection keeps running while the screen is stopped, rendering to nothing and keeping upstream work alive; repeatOnLifecycle(STARTED) cancels the block when the lifecycle drops below STARTED and restarts it when it returns.

  • Call it from viewLifecycleOwner in Fragments.
  • flowWithLifecycle is the single-flow operator form.
  • Paired with WhileSubscribed, the whole chain from database to screen sleeps in the background.

collectAsStateWithLifecycle is the Compose equivalent.

collectAsState or collectAsStateWithLifecycle in Compose?

collectAsStateWithLifecycle for anything backed by real work, because it stops collecting below STARTED; collectAsState keeps collecting as long as the composable is in the composition, which on Android includes the whole time the app is in the background.

  • It comes from lifecycle-runtime-compose.
  • Default minimum state is STARTED, adjustable.
  • Cheap in-memory flows can use either.

Google's Compose samples use the lifecycle variant everywhere.

How do you test a ViewModel that uses viewModelScope?

Replace Dispatchers.Main with a test dispatcher through Dispatchers.setMain in a JUnit rule, inject any other dispatcher the ViewModel uses, run the test in runTest, and use advanceUntilIdle or Turbine to observe state.

  • StandardTestDispatcher queues work so intermediate states can be asserted; UnconfinedTestDispatcher runs eagerly.
  • Virtual time makes delay free.
  • Hard-coded Dispatchers.IO is untestable; take dispatchers as constructor parameters.

Turbine's test { awaitItem() } is the cleanest way to assert a sequence of StateFlow values.

What does it mean for a function to be main-safe, and how do you make one?

A main-safe suspend function can be called from the main thread without blocking it, because it moves any blocking work to the right dispatcher inside itself with withContext; callers never need to know.

  • Retrofit and Room suspend functions are main-safe already.
  • A repository wrapping a blocking file or SDK call wraps that call in withContext(Dispatchers.IO).
  • The convention lets ViewModels launch on Main.immediate and stay simple.

If a caller has to add withContext(IO) around your function, the function was not main-safe.