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.
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.
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
| Thread | Coroutine | |
|---|---|---|
| What it is | An OS resource with its own stack, scheduled by the kernel | An object holding the local variables and the next step; scheduled by a dispatcher |
| Cost | About 1 MB of stack, expensive to create and to switch between | A few hundred bytes, microseconds to create |
| Waiting | Blocks: the thread sits idle until the result arrives | Suspends: the thread is released and reused |
| How many | Hundreds before the system suffers | Hundreds of thousands on a small pool of threads |
| Where the code runs | On that thread, always | On 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.
- 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)
}
- Suspension points are the calls that can pause:
delay,withContext,await,join,Flow.collect,Channel.receive, and any other suspend function. Ordinary code between them runs without interruption. - Sequential by default. Two suspend calls in a row run one after the other. Concurrency needs a builder (
async,launch), never comes for free. - Main-safe is the convention that a suspend function may be called from the main thread without harm because it moves blocking work to the right dispatcher itself. Retrofit and Room follow it; your repository functions should too.
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
}
}
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.
| Builder | Returns | Blocks the caller? | Exceptions | Use it for |
|---|---|---|---|---|
launch | Job | No | Thrown up the parent hierarchy the moment they happen | Fire-and-forget work: handle a click, log an event, start a collection |
async | Deferred<T> | No | Stored in the Deferred and rethrown by await(); a non-supervisor parent is still cancelled immediately | Several results computed in parallel and combined |
runBlocking | T | Yes, until the block and its children finish | Rethrown to the calling thread | Tests 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()
}
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).
- Google's architecture samples use
viewModelScope.launchfor every user action andcoroutineScopewithasyncinside repositories to fan out. - kotlinx.coroutines tests use
runBlocking's successorrunTest, which skips delays; plainrunBlockingin 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
| Element | What it controls | Default |
|---|---|---|
Job | Lifecycle, cancellation, the parent-child tree | A new child of the parent's Job |
CoroutineDispatcher | Which thread or pool runs the coroutine | Inherited; Dispatchers.Default if there is no parent |
CoroutineName | A label for debugging and thread dumps | None |
CoroutineExceptionHandler | Last resort for uncaught exceptions in a root coroutine | None: 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]
| Dispatcher | Threads | For | Notes |
|---|---|---|---|
Dispatchers.Main | The Android main thread | UI updates, lifecycle work, anything touching Views | Always posts to the main Looper, even if you are already on it |
Dispatchers.Main.immediate | The main thread | The same, without a needless hop | Runs immediately when already on main; what viewModelScope and lifecycleScope use |
Dispatchers.IO | Up to 64 threads (or the core count if larger), created on demand | Blocking calls: files, sockets, JDBC, old SDKs | Sized for waiting, not computing; a thread parked on I/O costs nothing |
Dispatchers.Default | As many threads as CPU cores, at least two | CPU-bound work: JSON parsing, sorting, diffing, image processing | More threads than cores would only add context switching |
Dispatchers.Unconfined | Whatever thread resumes it | Almost nothing in application code | Starts 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) }
}
- IO and Default share a thread pool. Since kotlinx.coroutines 1.6,
Dispatchers.IOis a view over the same scheduler asDefaultwith a higher parallelism limit, so switching between them often stays on the same thread and costs almost nothing. - Limit parallelism instead of creating pools.
Dispatchers.IO.limitedParallelism(4)gives a dispatcher that never runs more than four coroutines at once, for example to protect a database that dislikes concurrency, without a new thread pool. - Executors can become dispatchers.
executor.asCoroutineDispatcher()wraps an existing thread pool, which is how legacy code and libraries with their own threads plug in.
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.
- 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
AndroidUiDispatcherfor recomposition, a Main-thread dispatcher that batches work per frame. - Hilt projects commonly inject dispatchers as qualified
CoroutineDispatcherinstances 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 --> [*]
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
- Completing is the state people forget: a parent whose own body has finished still waits for every child before it is Completed.
- A regular Job fails when any child fails and then cancels the rest. A
SupervisorJoblets each child fail on its own, which is what scopes that host independent work use. - Join versus await.
joinwaits for completion and never throws the coroutine's exception;awaitwaits for the value and rethrows the failure.
- 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
posts cancels its sibling and fails the load profile parent. The supervisor scope above stops the spread there.- A scope owns its children.
scope.cancel()cancels every coroutine launched in it, recursively. This is why cancellingviewModelScopeinonClearedstops all the ViewModel's work at once. - A parent waits.
coroutineScope { launch { }; launch { } }returns only after both launches complete. No coroutine is ever lost. - 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 fails | Siblings are cancelled, the scope rethrows | Siblings keep running, the failure stays with that child |
| Use for | Work that only makes sense as a whole: fetch user and posts, then combine | Independent work: several widgets loading on one screen |
| Who sees the exception | The caller of coroutineScope | A 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
}
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 { }.
- 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
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
- What checks for you: every suspend function in kotlinx.coroutines (
delay,withContext,await,collect). Between suspension points, useisActive,ensureActive()oryield(). - CancellationException is normal. It is how cancellation travels. Catching
Exceptioncatches it too, and a coroutine that swallows it keeps running as a zombie. Rethrow it. - Resources are released with
try/finallyoruse { }; anything that must suspend during cleanup runs insidewithContext(NonCancellable), because a cancelled coroutine cannot otherwise suspend. withTimeoutthrowsTimeoutCancellationException, which cancels only the block inside it and is an ordinary exception to the caller.
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.
- Retrofit and OkHttp cancel the HTTP call when the coroutine is cancelled, through
invokeOnCancellationin their suspend adapter. - Compose's
LaunchedEffectcancels 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]
CoroutineExceptionHandler get a say.launchtreats an exception as a failure immediately: the child is cancelled, the parent is notified, and unless something supervises, the whole tree goes down.asynckeeps the exception forawait(). But if theasynclives in a regular scope, its failure still cancels that scope at once; only the rethrow is deferred, not the damage.CoroutineExceptionHandleronly works where the climb ends: on a root coroutine's scope, or on a direct child of a supervisor. Installed on a nested child it is silently ignored.- The reliable tool is a
try/catcharound the suspend call inside the coroutine, which keeps the exception from ever becoming a Job failure.
// 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 }
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"]
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)) }
| Operator | Does | Reach for it when |
|---|---|---|
map, filter, transform | Reshape each value | Always; they are lazy and cheap |
flowOn | Sets the dispatcher for everything upstream | The producer or an operator does heavy work |
catch, retry, retryWhen | Handle upstream failures, optionally resubscribe | Network-backed flows |
buffer | Lets the producer run ahead of a slow collector | Producer is fast, collector is slow |
conflate | Keeps only the latest value when the collector lags | UI state where intermediate values do not matter |
collectLatest, flatMapLatest | Cancels the previous block or inner flow when a new value arrives | Search-as-you-type, anything where only the newest input counts |
combine, zip | Merge several flows: latest of each, or pairwise | Screen state built from several sources |
debounce, distinctUntilChanged | Quiet a noisy source | Text input, sensors |
first, toList, collect | Terminal operators that start the flow | One value, all values, or a running collection |
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.
- 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 withcollectAsStateWithLifecycle. - 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
Flow | StateFlow | SharedFlow | Channel | |
|---|---|---|---|---|
| Temperature | Cold: runs per collector | Hot | Hot | Hot |
| Holds | Nothing | Exactly one current value, needs an initial one | A replay cache of N values (default 0) | A buffer of unconsumed values |
| Duplicate values | Delivered | Skipped when equals | Delivered | Delivered |
| Collectors | Each gets its own run | Many, all see the same value | Many, all see every emission after they joined | Each value goes to one receiver |
| No collector present | Nothing runs | Value is kept | Emission is dropped unless replay or buffer keeps it | Value waits in the buffer |
| Use for | Data sources, pipelines | UI state | Broadcast events with several listeners | One-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) }
}
stateInandshareInconvert a cold flow into a hot one that runs in a scope.SharingStarted.WhileSubscribed(5_000)starts the upstream when the first collector appears and stops it five seconds after the last one leaves: long enough to survive a rotation, short enough to stop work when the app is backgrounded.update { }applies a function atomically, which matters when several coroutines change the same state.- State versus events. "The screen is loading" is state and belongs in a StateFlow. "Navigate to details" is an event: replaying it on rotation would navigate twice. Events go in a SharedFlow with a buffer, or a Channel when exactly one consumer must see each one and none may be lost.
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.
- Google's guidance is StateFlow for UI state,
WhileSubscribed(5_000)forstateIn, and events modelled as state where possible. - Now in Android (Google's sample app) builds every screen state with
combineover repository flows andstateInin 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]
| Capacity | Behaviour |
|---|---|
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 |
CONFLATED | Only the latest value is kept; older unconsumed ones are dropped |
UNLIMITED | Never 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
}
- Flow's
bufferandcallbackFloware channels underneath;callbackFlow'strySendis 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
repeatOnLifecycle restarts its block every time the lifecycle reaches the state and cancels it every time it drops below.| Tool | What it is | Cancelled when |
|---|---|---|
viewModelScope | SupervisorJob() + Dispatchers.Main.immediate on every ViewModel | onCleared(): the screen is finished, not on rotation |
lifecycleScope | The 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 state | Each time the lifecycle drops below; restarted when it returns |
flowWithLifecycle(lifecycle, state) | The same idea as an operator on one flow | Same |
collectAsStateWithLifecycle() | Compose: collect a flow into State, paused below STARTED | The composable leaves the composition |
LaunchedEffect(key) | Compose: a coroutine tied to the composition | The composable leaves, or the key changes |
CoroutineWorker | WorkManager 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)
}
- Why
repeatOnLifecycleand notlaunchIn(lifecycleScope):lifecycleScopeis only cancelled on destroy, so a plain collection keeps rendering to an invisible screen and keeps the upstream awake in the background.repeatOnLifecyclestops at STOPPED and restarts at STARTED. - Why
Main.immediate: a coroutine launched from the main thread starts running right away instead of after a trip through the message queue, so the first state update lands in the same frame. - Fragments use
viewLifecycleOwnerfor anything that touches views; the Fragment's own lifecycle outlives its view. - Work that must survive the screen (upload, sync) does not belong in
viewModelScope. Hand it to aCoroutineWorkeror an application-scoped coroutine.
lifecycle-runtime-composeshipscollectAsStateWithLifecycle; Google's Compose guidance prefers it over plaincollectAsStatefor any flow backed by real work.- WorkManager's
CoroutineWorkerrunsdoWork()onDispatchers.Defaultand cancels the coroutine when the constraints stop being met. - Hilt's
@HiltViewModelplusviewModelScopeis 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()
}
}
}
StandardTestDispatcherqueues coroutines until you calladvanceUntilIdle(),runCurrent()oradvanceTimeBy(), which lets you assert intermediate states.UnconfinedTestDispatcherruns them eagerly, which is simpler when ordering does not matter.- Inject dispatchers. A ViewModel that hard-codes
Dispatchers.IOcannot be controlled from a test; take aCoroutineDispatcherin the constructor. - Never-ending flows (a
stateInwithWhileSubscribed) are collected inbackgroundScopeinsiderunTest, or with Turbine, so the test can finish.
- Turbine (Cash App) is the standard library for asserting on flows one item at a time.
- kotlinx-coroutines-test replaced the older
runBlockingTestwithrunTestin 1.6; virtual time means a test with a thirty seconddelayfinishes 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)"]
// 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)
}
- The dispatcher is a context element called a
ContinuationInterceptor. It wraps every continuation so thatresumeWithdoes not run the code in place but schedules it on the dispatcher's thread, which is how a coroutine resumes on Main after a callback fired on a background thread. - No magic threads. A coroutine is just this object plus a dispatcher that knows where to call
resumeWith. Everything else (Job, cancellation, scopes) is a library on top of that. - Suspend functions are cheap to call when nothing suspends: the fast path returns the value directly, and the state machine object is the only allocation.
- 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
GlobalScopefor anything: nothing cancels it, nothing reports it.- Catching
Exceptionor usingrunCatchingaround suspend calls without rethrowingCancellationException. - Blocking inside a coroutine:
Thread.sleep, a synchronous HTTP client,runBlockingon the main thread. The dispatcher's thread is held and the app freezes or starves. - CPU work on
Dispatchers.IO, orwithContext(IO)around calls that are already main-safe. - Collecting in
onCreatewithlifecycleScope.launch { flow.collect }: keeps running in the background. UserepeatOnLifecycleorcollectAsStateWithLifecycle. asyncwhose result is never awaited in a regular scope: the failure still cancels the scope, silently.- Exposing
MutableStateFlowfrom a ViewModel; exposeStateFlowthroughasStateFlow(). SharingStarted.Eagerlyfor astateInthat watches a database: the upstream never stops. UseWhileSubscribed(5_000).- Passing a
SupervisorJob()tolaunch: breaks the parent link instead of supervising. - Loading in a ViewModel's
initwithout an idempotency guard, then wondering why a shared ViewModel loaded twice. - Resuming a continuation twice in a
suspendCancellableCoroutinebridge when a callback fires more than once.
Recap
- A coroutine is a suspendable computation, not a thread. Suspending releases the thread; blocking holds it.
suspendfunctions pause at suspension points and are sequential unless a builder introduces concurrency. Make them main-safe by switching dispatchers inside.launchreturns a Job and throws immediately;asyncreturns a Deferred and rethrows atawaitbut still cancels a regular parent;runBlockingis for tests andmain().- 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 (
immediatefrom lifecycle scopes), IO for blocking calls, Default for CPU.withContextswitches 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.
coroutineScopefails together;supervisorScopeandSupervisorJobisolate direct children. - Cancellation is cooperative: honoured at suspension points or
ensureActive(); never swallowCancellationException; useNonCancellablefor cleanup that suspends; timeouts are cancellation. - Exceptions climb the Job tree;
CoroutineExceptionHandleronly counts at the root or under a supervisor; catch expected failures at the call site. - Flow is cold and runs per collector;
flowOnmoves upstream work;catch,buffer,conflate,collectLatest,combineshape the pipeline;callbackFlowbridges listeners. - StateFlow holds UI state (initial value, conflated, equality-skipped); SharedFlow broadcasts events; Channels deliver each value once with backpressure;
stateInwithWhileSubscribed(5_000)shares a cold flow safely. viewModelScopeandlifecycleScopeare supervisor scopes onMain.immediate; collect withrepeatOnLifecycle(STARTED)orcollectAsStateWithLifecycle; test withrunTest, 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
ContinuationInterceptorin 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
asyncfailure still cancels a non-supervisor parent right away; only the rethrow waits forawait. coroutineScopeandwithContextare scoping suspend functions, not builders.- Never call
runBlockingon 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.
coroutineContextis 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.Mainalways posts, which costs a frame and can reorder work. - When called from a background thread,
immediatebehaves likeMain. - 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.
withContextis the tool for "do this part on IO, then continue here".launchis the tool for "start this and move on".withContextwith 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.
isActiveis true in Active and Completing;isCancelledfrom Cancelling on.joinwaits without throwing;awaiton a Deferred rethrows.invokeOnCompletiongets 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.GlobalScopeis 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 failedlaunchchild needs a handler or it crashes; a failedasyncchild surfaces atawait. - 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(),isActiveoryield(). - Cancellation is delivered as a
CancellationExceptionsofinallyblocks 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)andrunCatchingboth catch it.- Rethrow it, or catch only the specific exceptions you expect.
- Timeouts use a subclass, so the same rule applies inside
withTimeoutblocks.
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
launchchild needs a handler in its context. - A root
asyncnever uses the handler; it waits forawait. - A
try/catchinside 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 andcallbackFloware cold.StateFlow,SharedFlowand channels are hot.stateInandshareInconvert 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.
Eagerlywould start at once and never stop;Lazilystarts 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
flowOncalls each apply to their own upstream segment. flowOninserts a buffer, so the producer can run ahead.callbackFlowandchannelFlowexist 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.
conflatesuits UI state; intermediate frames do not matter.collectLatestandflatMapLatestsuit 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
viewLifecycleOwnerin Fragments. flowWithLifecycleis 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.
StandardTestDispatcherqueues work so intermediate states can be asserted;UnconfinedTestDispatcherruns eagerly.- Virtual time makes
delayfree. - Hard-coded
Dispatchers.IOis 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.