Scope
Owns the coroutine. Cancelling the scope cancels its unfinished children.
Structured concurrency, scopes, dispatchers, cancellation, exception handling, and reactive streams with Flow, StateFlow, and Channels.
Coroutines are the single most-asked Android topic - if you prepare nothing else deeply, prepare this. Modern Android is coroutine-first, so interviewers use it to gauge whether you understand asynchrony or just copy patterns.
Learn launch, async, dispatchers, scopes, and cancellation first. Add basic
Flow collection and StateFlow next. Exception propagation, Channels, custom
Flow builders, and operator internals are follow-up material rather than a
starting point.
suspend really does (CPS / state machine), coroutines vs threads, builders (launch, async, withContext, runBlocking).Job vs SupervisorJob, parent/child cancellation, coroutineScope vs supervisorScope.Main/IO/Default, CoroutineContext elements, withContext, limitedParallelism.isActive/ensureActive, CancellationException, timeouts.launch vs async exceptions surface, CoroutineExceptionHandler, supervision.map, flatMapLatest, combine, debounce), flowOn, backpressure, catch/retry.stateIn/shareIn, WhileSubscribed.viewModelScope, lifecycleScope, repeatOnLifecycle, collectAsStateWithLifecycle.runTest, test dispatchers, Turbine.A heavy dose of “what’s the output / in what order?” (concurrency with
delay, collectLatest, async exceptions), comparison questions
(StateFlow vs SharedFlow, combine vs zip), and practical design
(“build search-as-you-type”, “expose state from a ViewModel”). They’re listening
for whether you understand why - e.g. the race condition flatMapLatest solves,
or why Thread.sleep in a coroutine is a bug.
Prep tip: be able to reason about which thread runs what and when a coroutine is cancelled for any snippet. Those two questions underlie most coroutine interview problems.
Frequently asked. Prioritize these in your first pass.
A coroutine is a block of work that can pause without blocking its thread. While one coroutine is suspended, the thread can run another one. When the suspended work is ready, its coroutine continues from the next line.
Owns the coroutine. Cancelling the scope cancels its unfinished children.
Chooses which thread or thread pool is available to run the work.
Pauses the coroutine, not necessarily the thread. delay is the classic example.
Tracks the work so it can be joined, cancelled, or checked for completion.
delay(100) records where the coroutine should resume and
gives the thread back. Thread.sleep(100) holds the thread
and prevents it from doing other work.
launch schedules a child and returns a Job
immediately. The parent continues until join() suspends it.
runBlocking {
println("1: parent starts")
val job = launch {
println("3: child starts")
delay(100)
println("4: child resumes")
}
println("2: parent keeps going")
job.join()
println("5: parent resumes")
} 1: parent starts2: parent keeps going3: child startsdelay(100)suspends4: child resumes5: parent resumes1 → 2 → 3 → 4 → 5
Both children start, then suspend at delay. The second delay
finishes first, so B2 is printed before A2.
runBlocking {
println("start")
val first = launch {
println("A1")
delay(200)
println("A2")
}
val second = launch {
println("B1")
delay(100)
println("B2")
}
joinAll(first, second)
println("end")
}
This order assumes the children use the same dispatcher as
runBlocking. With different threads, do not rely on the
initial order of A1 and B1.
start → A1 → B1 → B2 → A2 → end When the ViewModel is cleared, its scope cancels both children. The parent also waits for its children before it completes.
flowOf(1, 2, 3, 4)
.map { it * 10 }
.filter { it >= 30 }
.collect { println(it) } 1, 2, 3, 410, 20, 30, 4030, 4030, 40A cold Flow does nothing until it is collected. Each operator handles a value and passes its result to the next step.
Core ideas you should be able to explain in plain language.
A thread is an OS-level construct with a large fixed stack (~1MB+ on the JVM). Creating thousands is expensive in memory and context-switching. A coroutine is a language-level construct - essentially a resumable computation - that runs on top of threads.
The key differences:
// 100k coroutines - fine. 100k threads - OutOfMemoryError.
repeat(100_000) {
launch { delay(1000); print(".") }
}
coroutines don’t replace threads - they multiplex work onto threads efficiently. “Lightweight” means the cost is a small state-machine object plus a continuation, not an OS thread.
Interview clincher: “Suspension releases the underlying thread; blocking holds it. That’s why a handful of Dispatchers.IO threads can serve thousands of concurrent suspended network calls.”
A suspend function is one that can pause without blocking the thread and resume later. The keyword is a contract: it can only be called from another suspend function or a coroutine.
Under the hood - Continuation Passing Style (CPS): the compiler rewrites a suspend function to take an extra hidden parameter, a Continuation (a callback for “what to do when I resume”). The function body is transformed into a state machine: each suspension point is a state, and local variables are saved on the continuation object.
suspend fun loadUser(): User {
val token = auth() // suspension point 1
val user = api(token) // suspension point 2
return user
}
Conceptually compiles to something like a switch over a label:
auth(continuation), save label = 1, return COROUTINE_SUSPENDED.auth completes, it invokes the continuation → re-enters at state 1, and so on.Why this matters:
suspend alone doesn’t move work off the main thread - you still need withContext(Dispatchers.IO) for blocking work. suspend means “can suspend,” not “runs in the background.”Both are coroutine builders, but they differ in what they return and how you use the result.
launch starts a coroutine and returns a Job. Use it for work whose outcome is completion rather than a returned value. It is still owned by its scope. Callers should be able to cancel it or observe failure, so “fire and forget” is a misleading mental model.async returns a Deferred<T>, a Job that also carries a result. You call .await() to get the value. Use it for concurrent work you need to combine.// Run two network calls concurrently, then combine.
suspend fun loadDashboard() = coroutineScope {
val user = async { api.getUser() }
val feed = async { api.getFeed() }
Dashboard(user.await(), feed.await())
}
Interview trap: calling async { ... }.await() immediately, one after another, runs them sequentially - you’ve lost the concurrency. Start all the async blocks first, then await them.
Exception nuance: async stores its failure in the Deferred, so await() rethrows it. But if that async is a regular child, its failure also cancels its parent immediately. Waiting to call await() does not prevent structured-concurrency propagation. A root launch reports an uncaught failure immediately; a root async exposes it through await().
A dispatcher decides which thread(s) a coroutine runs on.
Dispatchers.Main - the Android UI thread. Use for touching views/Compose state. Main.immediate runs synchronously if you’re already on Main, avoiding an extra re-dispatch.Dispatchers.IO - an elastic dispatcher tuned for blocking I/O: legacy network calls, file reads, and blocking database APIs. Its default parallelism limit is at least 64 or the number of CPU cores (whichever is larger), and it shares threads with Default.Dispatchers.Default - a pool sized to the number of CPU cores, for CPU-bound work: parsing, sorting, JSON, image processing.Dispatchers.Unconfined - starts in the calling thread and resumes in whatever thread the suspending function used. Rarely used in app code; mainly for specific library/testing cases.suspend fun loadAndProcess(): Result = withContext(Dispatchers.IO) {
val raw = legacyBlockingApi.download() // blocking I/O → IO pool
withContext(Dispatchers.Default) {
parseAndSort(raw) // CPU-heavy → Default
}
}
Key points interviewers probe:
withContext(Dispatchers.IO) is preferred over launch(Dispatchers.IO) for “do this blocking thing and give me the result.”limitedParallelism(n) carves a bounded view out of a dispatcher to cap concurrency (e.g. one network host).runBlocking is a bridge from regular blocking code into the coroutine world. It starts a coroutine and blocks the current thread until that coroutine and all its children complete.
fun main() = runBlocking { // blocks main until done
val data = repository.load()
println(data)
}
Where it’s appropriate:
main() functions and simple scripts.runTest is now preferred for coroutine tests (it skips delays and controls virtual time).Where it’s dangerous:
withContext/coroutineScope instead.Contrast with coroutineScope: both wait for children, but coroutineScope suspends (releases the thread) while runBlocking blocks (holds the thread). Inside coroutine code you want coroutineScope; only use runBlocking to enter coroutine code from a non-suspending context.
These are pre-built CoroutineScopes tied to Android lifecycles, so your coroutines are cancelled automatically.
viewModelScope - an extension on ViewModel. Cancelled in onCleared(), i.e. when the ViewModel is destroyed for good (the screen is finished, not just rotated). Uses Dispatchers.Main.immediate + a SupervisorJob. This is where most app coroutines live, since the ViewModel survives configuration changes.lifecycleScope - an extension on a LifecycleOwner (Activity/Fragment). Cancelled when the lifecycle reaches DESTROYED. Use sparingly - for UI-only work that genuinely must follow the view, not the data.class FeedViewModel : ViewModel() {
fun refresh() = viewModelScope.launch { // cancelled in onCleared()
_state.value = repo.load()
}
}
Why this matters: before these existed, you manually cancelled jobs in onDestroy/onCleared - easy to forget, causing leaks and callbacks firing on dead screens. Structured concurrency + lifecycle scopes make that automatic.
Gotchas:
lifecycleScope - on rotation the Activity is destroyed and the work is cancelled and restarted. Put it in the ViewModel.GlobalScope is not lifecycle-aware - coroutines launched there outlive everything and leak. Avoid it.lifecycleScope with repeatOnLifecycle(STARTED) so collection pauses in the background.The common Flow builders:
// 1. flow { } - the general builder; call emit() inside
val f1 = flow {
emit(1)
delay(100)
emit(2)
}
// 2. flowOf(...) - fixed set of values
val f2 = flowOf("a", "b", "c")
// 3. asFlow() - from a collection, range, or sequence
val f3 = (1..5).asFlow()
val f4 = listOf("x", "y").asFlow()
// 4. channelFlow { } / callbackFlow { } - emit from other contexts/callbacks
val f5 = callbackFlow {
val l = listener { trySend(it) }
register(l); awaitClose { unregister(l) }
}
// 5. MutableStateFlow / MutableSharedFlow - hot flows you push into
val state = MutableStateFlow(0)
How to pick:
flow { } - most cases; sequential emission, context-preserving (use flowOn to switch dispatchers).flowOf / asFlow - wrap existing values/collections.channelFlow / callbackFlow - when you must emit from a callback or multiple coroutines/threads (a plain flow { } forbids cross-context emission).StateFlow / SharedFlow - hot, shared, observable app state/events.Note: flow { }, flowOf, and asFlow are cold - the block runs per collector, only when collected. StateFlow/SharedFlow are hot.
Choose based on how many values arrive and whether producing them may suspend.
| API shape | Values | Can suspend between values? | Typical use |
|---|---|---|---|
suspend fun load(): User | one result | yes | one network/database operation |
fun observe(): Flow<User> | zero to many over time | yes | database updates, UI state, events |
fun parse(): Sequence<Row> | many, pulled synchronously | no | lazy in-memory or blocking iteration |
suspend fun user(id: Long): User = api.fetchUser(id) // one eventual answer
fun observeUser(id: Long): Flow<User> =
dao.observeUser(id) // updates over time
A suspend function does not imply a background thread; it returns one result and may suspend while obtaining it. A cold Flow is also lazy, but collection can receive multiple values and is cancelled with the collecting coroutine. A Sequence is lazy but synchronous. Its iterator cannot call suspending APIs.
Interview trap: do not return Flow merely to wrap one network response. A suspend function is clearer unless the operation genuinely emits progress, retries as values, or later updates.
A Flow is cold - the chain of intermediate operators (map, filter, onEach…) just describes work. Nothing runs until a terminal operator starts collection. Terminal operators are suspend functions (they need a coroutine).
val pipeline = flow { emit(1); emit(2) }.map { it * 10 }
// Nothing has run yet - pipeline is just a recipe.
pipeline.collect { println(it) } // NOW it runs: 10, 20
Common terminal operators:
collect { } - the fundamental one; process every value.toList() / toSet() - gather into a collection.first() / firstOrNull() - take the first value (and cancel upstream).single() - expect exactly one value.reduce / fold - accumulate to a single result.count() - count emissions.launchIn(scope) - collect in a given scope without a lambda (usually after onEach).flow.onEach { render(it) }.launchIn(viewModelScope) // fire-and-collect
Why this matters: the classic bug is building a flow with onEach/map and wondering why the side effects never fire - there’s no terminal operator, so collection never starts. “Cold flows do nothing until collected” is the point being tested.
StateFlow and LiveData are both observable, lifecycle-friendly state holders, but StateFlow is the modern default in a coroutine-first codebase.
LiveData | StateFlow | |
|---|---|---|
| Always has a value | No; it may be unset | Yes (requires initial value) |
| Lifecycle-aware | Built in | Via repeatOnLifecycle / collectAsStateWithLifecycle |
| Operators | Few (map, switchMap) | Full Flow operator set |
| Threading | Main-thread bound | Any dispatcher |
| Pure Kotlin (testable, multiplatform) | No (Android dep) | Yes |
Choose StateFlow when you want a single source of truth that’s always set, you need Flow operators (combine, debounce, flatMapLatest), or you’re in a shared/KMP module with no Android dependency. Collect it with lifecycle awareness (repeatOnLifecycle in Views or collectAsStateWithLifecycle in Compose).
private val _state = MutableStateFlow(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
The catch: StateFlow isn’t lifecycle-aware on its own. Collect it safely so you don’t waste work while the UI is in the background:
// Compose
val state by viewModel.state.collectAsStateWithLifecycle()
// Views
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { render(it) }
}
}
Reach for SharedFlow instead for one-off events (navigation, snackbars) where you don’t want a “current value” replayed on rotation.
Common implementation choices, debugging, and trade-offs.
A CoroutineContext is an indexed set of elements that defines how a coroutine behaves. It’s like a map keyed by element type, and elements combine with +.
The main elements:
Job - the coroutine’s lifecycle handle (cancellation, parent/child relationship).CoroutineDispatcher - which thread(s) it runs on.CoroutineName - a name for debugging/logging.CoroutineExceptionHandler - last-resort handler for uncaught exceptions.val scope = CoroutineScope(Dispatchers.Main + SupervisorJob() + CoroutineName("ui"))
scope.launch(Dispatchers.IO + CoroutineName("download")) {
// context here = parent's, with Dispatcher and Name overridden
}
How it composes (important):
Job that is a child of the parent’s Job - that’s what wires up structured concurrency. (You don’t inherit the parent’s Job instance; you become its child.)coroutineContext[Job], coroutineContext[CoroutineDispatcher] let you read elements.They all run code in a coroutine, but serve different purposes.
withContext(ctx) { } - a suspend function that runs a block in a different context (usually a different dispatcher) and returns its result. It does not start a concurrent coroutine - it suspends the current one until the block finishes. Use it to switch threads for a piece of work.launch { } - starts a new concurrent coroutine, returns a Job, no result. Fire-and-forget.async { } - starts a new concurrent coroutine, returns a Deferred<T> you await(). Use for concurrent work you’ll combine.// withContext: switch to IO, get the result back, sequential
suspend fun load() = withContext(Dispatchers.IO) { api.fetch() }
// async: two things at once
suspend fun loadBoth() = coroutineScope {
val a = async { api.fetchA() }
val b = async { api.fetchB() }
a.await() to b.await()
}
The common mistake: using async { }.await() immediately to switch threads:
val data = async(Dispatchers.IO) { fetch() }.await() // ❌ pointless
val data = withContext(Dispatchers.IO) { fetch() } // ✅ clearer, cheaper
If you’re going to await right away, you want withContext - async is only worth it when you start multiple and await them later.
Rule of thumb: one result, switch context → withContext; concurrent work to combine → async; side-effect with no result → launch.
Structured concurrency means every coroutine runs inside a scope, and a scope doesn’t finish until all the coroutines it launched have finished. Coroutines form a parent–child tree.
This gives you three guarantees:
Why it matters on Android: viewModelScope is cancelled in onCleared(), and lifecycleScope follows the lifecycle. Tie your coroutines to these and work is automatically cancelled when the user leaves - no manual teardown, no callbacks firing on a dead screen.
class FeedViewModel : ViewModel() {
fun refresh() = viewModelScope.launch {
val items = repo.loadFeed() // cancelled automatically if the
_state.value = State.Loaded(items) // ViewModel is cleared mid-flight
}
}
Follow-up to be ready for: use supervisorScope (or a SupervisorJob) when you don’t want one child’s failure to cancel its siblings - e.g. loading several independent widgets where one failing shouldn’t blank the rest.
Use async inside a coroutineScope to start each piece concurrently, then await them:
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { api.profile() }
val feed = async { api.feed() }
val notifs = async { api.notifications() }
Dashboard(profile.await(), feed.await(), notifs.await())
}
All three calls run at the same time, so total latency ≈ the slowest one, not the sum.
Why coroutineScope? It provides structured concurrency: if any child fails, the others are cancelled and the exception propagates out of loadDashboard. It also waits for all children before returning. Never use GlobalScope.async here.
Common mistakes:
async { a() }.await() then async { b() }.await() runs them one after another. Start all the asyncs first, then await.supervisorScope and handle each await() in its own try/catch.For a dynamic list of inputs, map then await all:
suspend fun loadAll(ids: List<Int>): List<Item> = coroutineScope {
ids.map { id -> async { api.item(id) } }.awaitAll()
} Cancellation is cooperative: cancelling a coroutine sets its Job to a cancelling state, but the coroutine only actually stops when it checks for cancellation. If your code never checks, it keeps running.
Suspending functions from kotlinx.coroutines (delay, withContext, yield, etc.) check automatically and throw CancellationException when cancelled. But a tight CPU loop won’t:
// ❌ Ignores cancellation - runs to completion even after cancel()
launch {
while (i < 1_000_000) { heavyStep(i++) }
}
// ✅ Cooperates
launch {
while (i < 1_000_000) {
ensureActive() // throws if cancelled
heavyStep(i++)
}
}
Ways to cooperate:
ensureActive() - throws CancellationException if cancelled.isActive - check the flag yourself (while (isActive) { }).yield() - checks for cancellation and lets other coroutines run.delay, etc.).Critical rules:
CancellationException is normal - don’t swallow it. A blanket try { } catch (e: Exception) { } will eat it and break cancellation. Catch specific exceptions, or rethrow CancellationException.withContext(NonCancellable) { } - the coroutine is already cancelling, so normal suspension would immediately throw.finally blocks run on cancellation, so they’re the place for non-suspending cleanup.Two builders cap how long a block may run:
withTimeout(ms) - throws TimeoutCancellationException if the block doesn’t finish in time.withTimeoutOrNull(ms) - returns null instead of throwing on timeout.// Throws on timeout - handle with try/catch
val data = try {
withTimeout(5_000) { api.fetch() }
} catch (e: TimeoutCancellationException) {
fallback()
}
// Returns null on timeout - clean for "best effort"
val data = withTimeoutOrNull(5_000) { api.fetch() } ?: fallback()
How it works: on timeout the block is cancelled (cooperatively - same rules as normal cancellation). So the timed work must reach a suspension point or check isActive, or the timeout won’t fire until it does.
Gotchas interviewers like:
TimeoutCancellationException is a subclass of CancellationException. If you cancel inside the block and catch broadly, you can accidentally swallow it - and a blanket catch (e: Exception) around withTimeout will catch the timeout but also risks eating real cancellation.finally still runs. If cleanup suspends, wrap it in withContext(NonCancellable).select/a separate delay instead.The behavior depends on the builder.
launch - an uncaught exception propagates immediately up the Job hierarchy. A root launch reports it to a CoroutineExceptionHandler (or the thread’s uncaught-exception handler). A child delegates handling to its parent.
async - stores the exception and rethrows it from await(). A CoroutineExceptionHandler does not consume a root async failure because the caller is expected to observe the Deferred.
val handler = CoroutineExceptionHandler { _, e -> Log.e("TAG", "caught $e") }
// Root launch → handler reports it
scope.launch(handler) { throw IOException() }
// async → must catch at await()
val deferred = scope.async { throw IOException() }
try { deferred.await() } catch (e: IOException) { /* handle */ }
The interview-grade nuance: in a normal coroutineScope, a child created with async still cancels its parent as soon as it fails. Catching only await() from inside that already-cancelled scope is often too late. Use supervisorScope when children should fail independently, and then handle each await() result.
Things that trip people up:
try/catch around launch { } doesn’t work - the builder returns immediately; the exception happens later, inside the coroutine. Put the try/catch inside the coroutine, or use a handler.CoroutineExceptionHandler is a last-resort reporter for an uncaught root failure, not a recovery mechanism. It cannot make the failed coroutine continue.CancellationException is special - it’s not treated as a failure and doesn’t trigger the handler.Job, one child’s exception cancels siblings; with SupervisorJob/supervisorScope, children fail independently and each needs its own handling.The difference is how a child’s failure affects its siblings and parent.
Regular Job - failure propagates both ways: a failing child cancels its parent, which cancels all the other children. One failure tears down the whole scope.
SupervisorJob - failure propagates downward only: a child failing does not cancel its siblings or the parent. Each child fails independently.
// Regular: if one fails, both are cancelled
coroutineScope {
launch { loadProfile() } // if this throws...
launch { loadFeed() } // ...this gets cancelled too
}
// Supervisor: independent children
supervisorScope {
launch { loadProfile() } // can fail alone
launch { loadFeed() } // keeps running regardless
}
coroutineScope { } uses a regular Job; supervisorScope { } uses a SupervisorJob. Same relationship as CoroutineScope(Job()) vs CoroutineScope(SupervisorJob()).
When to use supervision: a screen loading several independent widgets where one failing shouldn’t blank the others; a viewModelScope-style scope where one failed operation shouldn’t kill all future ones.
Two gotchas interviewers love:
SupervisorJob, each child needs its own exception handling - a CoroutineExceptionHandler must be installed on the child launch, not just the scope, because the failure doesn’t propagate up to the scope’s handler in the same way.SupervisorJob() inside a child launch(SupervisorJob()) does not make its children supervised - supervision comes from the scope’s Job, and passing a Job to a builder breaks the parent link. Use supervisorScope { } instead.GlobalScope launches coroutines that live for the entire application lifetime and belong to no parent. That breaks structured concurrency and causes real problems:
GlobalScope.launch capturing a ViewModel or Context leaks it.// ❌ leaks, never cancelled, error goes nowhere useful
GlobalScope.launch { repo.sync() }
// ✅ tied to the ViewModel lifecycle
viewModelScope.launch { repo.sync() }
Use a lifecycle-bound scope instead: viewModelScope, lifecycleScope, or an application-scoped CoroutineScope you create and inject (with a SupervisorJob) for genuinely app-lifetime work (e.g. a sync that must outlive a screen).
@Singleton
class AppScope @Inject constructor() :
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default)
The rare legit case: truly application-lifetime, fire-and-forget work with no lifecycle - but even then, an injected app scope is preferable because it’s testable and controllable. GlobalScope is marked @DelicateApi for exactly these reasons.
Cold flow (flow { }, flowOf, Room/Retrofit flows): the producer block runs per collector, starting only when collected. Two collectors get two independent executions from the start. No collector = no work.
val numbers = flow {
println("start") // runs each time someone collects
emit(1); emit(2)
}
Hot flow (StateFlow, SharedFlow): emits regardless of collectors, and all collectors share the same stream. Late collectors miss past emissions (except replay/the current value).
Cold (Flow) | Hot (StateFlow / SharedFlow) | |
|---|---|---|
| Starts when | collected | exists independently |
| Per-collector execution | yes | shared |
| Has a current value | no | StateFlow: yes / SharedFlow: optional replay |
| Use for | one-shot data, transformations | observable app state, events |
StateFlow = hot, always has one current value, conflated, deduplicated (distinctUntilChanged built in). Great for UI state.
SharedFlow = hot, configurable replay and buffer, no “current value” requirement. Great for one-off events (navigation, snackbars) where you don’t want replay on rotation.
Bridging them: convert a cold flow to hot with stateIn / shareIn, so an upstream (e.g. a DB query) runs once and is shared across collectors instead of re-running per subscriber.
These operators hook into a flow’s lifecycle without changing its values - useful for loading states, logging, and cleanup.
repository.observe()
.onStart { emit(UiState.Loading) } // before the first upstream value
.onEach { log("emitted $it") } // for each value, as it passes
.onCompletion { cause -> // when the flow finishes (or fails)
if (cause != null) log("failed: $cause") else log("done")
}
.catch { emit(UiState.Error) }
.collect { render(it) }
onStart { } - runs before collection begins; can emit values (great for an initial Loading state).onEach { } - a side effect per value; returns the value unchanged. Pairs with launchIn to collect without a collect block.onCompletion { cause -> } - runs when the flow terminates for any reason: normal completion (cause == null), error (cause != null), or cancellation. Use it for cleanup or final logging. Unlike catch, it does not swallow the exception - it just observes it.onEmpty { } - runs if the flow completed without emitting anything; can emit a default.onCompletion vs finally: onCompletion is the declarative, flow-aware way to run teardown and sees the terminal cause, including downstream cancellation - clearer than wrapping collect in try/finally.
// Collect without a trailing lambda:
flow.onEach { render(it) }.launchIn(viewModelScope) The *Latest variants cancel the in-progress work when a new value arrives.
collect processes every emission to completion, sequentially. If processing is slow, emissions queue up.
collectLatest starts processing each value, but if a new value arrives before the current block finishes, it cancels the current block and restarts with the new value.
flow {
emit("A"); delay(10); emit("B")
}.collectLatest { value ->
println("start $value")
delay(100) // slow work
println("done $value") // only reached for the LAST value
}
// Output: start A, start B, done B (A's work was cancelled by B)
flatMapLatest / mapLatest apply the same idea to transformations - cancel the previous inner flow/computation when upstream emits again. This is the common search-as-you-type pattern:
queryFlow
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q -> repo.search(q) } // cancels the stale search
.collect { render(it) }
When to use which:
collectLatest / flatMapLatest.collect (with buffer if needed).Gotcha: with collectLatest, cancellation means the slow block’s later lines may never run - don’t rely on it for must-complete side effects.
Both merge multiple flows, but emit on different triggers.
zip pairs emissions one-to-one, in lockstep. It waits until both flows have a new value, then emits a pair. It completes when either flow completes. Use it to pair up corresponding items.
combine emits whenever any flow emits, using that flow’s newest value plus the latest value of the others. It needs every flow to have emitted at least once before the first emission.
val a = flowOf(1, 2, 3)
val b = flowOf("x", "y")
a.zip(b) { n, s -> "$n$s" } // [1x, 2y] - pairs, stops at shorter
a.combine(b) { n, s -> "$n$s" } // e.g. [3x, 3y] or [1x,2x,3x,3y]… - latest of each
When to use which:
combine - building UI state from several independent sources: combine(user, settings, network) { ... }. Any source changing should recompute the result with the latest of the others. This is the common one in apps.zip - genuinely paired streams where item N of one corresponds to item N of the other (e.g. requests with their responses).Gotchas:
combine’s output count is non-deterministic - it depends on timing. Don’t assume a fixed number of emissions.combine won’t emit until every input has emitted once, so give each source an initial value (a StateFlow always has one, which is why it pairs well with combine).By default a Flow is sequential: the producer waits for the collector to finish processing each value before emitting the next (suspension is the natural backpressure). When the collector is slower than the producer, you choose a strategy:
buffer(capacity) - run producer and collector concurrently. Emissions go into a buffer so the producer doesn’t wait; the collector drains it. Speeds up pipelines where both sides do real work.
flow.buffer().collect { slowProcess(it) } // producer keeps emitting into buffer
conflate() - keep only the latest value; if the collector is busy, intermediate emissions are dropped. Equivalent to buffer(CONFLATED).
fastSensor.conflate().collect { render(it) } // skip stale frames, render newest
collectLatest { } - like conflate, but it cancels and restarts the collector block for each new value (rather than dropping after processing starts).
How to choose:
buffer.conflate.collectLatest.buffer also accepts an onBufferOverflow policy (SUSPEND, DROP_OLDEST, DROP_LATEST) for fine control.
The problem: a plain lifecycleScope.launch { flow.collect { } } keeps collecting even when the app is in the background. The UI isn’t visible, but the flow still does work and holds references - wasted CPU/battery and a potential crash if you touch views.
repeatOnLifecycle(STATE) suspends, and runs its block only while the lifecycle is at least in that state, cancelling it when the lifecycle drops below and restarting it when it comes back.
class MyFragment : Fragment() {
override fun onViewCreated(view: View, b: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) } // collected only while STARTED
}
}
}
}
What happens: when the app goes to the background (below STARTED), the collection coroutine is cancelled (unsubscribing from the flow); when it returns to the foreground, the block restarts and re-collects. For a StateFlow, you immediately get the current value back.
Equivalents / related:
flowWithLifecycle(lifecycle, STARTED) - operator form for a single flow.collectAsStateWithLifecycle() does the same lifecycle-aware collection automatically - prefer it over collectAsState().Common bug it prevents: using LATEST/launchWhenStarted (deprecated) only paused the coroutine but kept the flow subscription alive upstream - repeatOnLifecycle actually cancels it, which (combined with WhileSubscribed upstream) lets the producer stop too.
The standard pattern: a private mutable state holder exposed as a public read-only flow, with one-off events on a separate SharedFlow.
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
// State: private mutable, public read-only
private val _state = MutableStateFlow(FeedUiState())
val state: StateFlow<FeedUiState> = _state.asStateFlow()
// One-off events: SharedFlow with replay = 0 (don't replay on rotation)
private val _events = MutableSharedFlow<FeedEvent>()
val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
init {
repo.observeFeed()
.onStart { _state.update { it.copy(loading = true) } }
.onEach { items -> _state.update { it.copy(loading = false, items = items) } }
.catch { _state.update { it.copy(loading = false, error = it.message) } }
.launchIn(viewModelScope)
}
fun onItemClick(id: String) = viewModelScope.launch {
_events.emit(FeedEvent.OpenDetail(id)) // navigation = event, not state
}
}
Why each choice:
asStateFlow() / asSharedFlow() expose read-only views so the UI can’t mutate state - enforcing unidirectional data flow._state.update { it.copy(...) } is atomic and works on immutable data class state.StateFlow (survives rotation, has a current value); transient actions like navigation/snackbars go in SharedFlow(replay = 0) so they fire once and don’t replay on configuration change.The UI collects state with collectAsStateWithLifecycle() (Compose) or repeatOnLifecycle (Views), and collects events to trigger navigation/toasts.
A clean search pipeline chains a few Flow operators, each solving a specific problem:
val results: StateFlow<SearchState> = queryFlow
.debounce(300) // 1. wait for typing to pause
.filter { it.length >= 2 } // 2. ignore tiny queries
.distinctUntilChanged() // 3. skip duplicate queries
.flatMapLatest { query -> // 4. cancel the previous search
repository.search(query)
.map { SearchState.Results(it) }
.onStart { emit(SearchState.Loading) }
.catch { emit(SearchState.Error(it.message)) }
}
.stateIn(viewModelScope, WhileSubscribed(5000), SearchState.Idle)
Why each operator:
debounce(300) - don’t fire a request on every keystroke; wait until the user pauses. Saves network calls.filter - skip 0–1 character queries that aren’t worth searching.distinctUntilChanged - if the debounced query equals the last one (e.g. type then backspace), don’t repeat the search.flatMapLatest - when a new query comes in, cancel the in-flight search for the old one. This prevents the classic race where a slow response for “ja” arrives after “java” and overwrites the correct results.onStart / catch model loading and error states inside the per-query inner flow.
This question tests whether you understand the race condition flatMapLatest solves - that’s the senior-level insight interviewers are listening for, not just naming debounce.
Use retryWhen (or retry) to re-subscribe to the upstream when it throws, with a delay between attempts.
fun <T> Flow<T>.retryWithBackoff(
maxAttempts: Int = 3,
initialDelay: Long = 500,
factor: Double = 2.0,
): Flow<T> = retryWhen { cause, attempt ->
if (attempt >= maxAttempts || cause !is IOException) {
false // stop retrying → error propagates
} else {
val delayMs = (initialDelay * factor.pow(attempt.toInt())).toLong()
delay(delayMs) // 500ms, 1s, 2s, ...
true // retry
}
}
repository.observe()
.retryWithBackoff()
.catch { emit(fallback) } // give up gracefully after retries
.collect { render(it) }
Key points:
retry(n) { predicate } - simpler: retry up to n times while the predicate is true.retryWhen { cause, attempt -> Boolean } - full control: inspect the exception type and attempt index, delay() for backoff, return true to retry / false to give up.IOException/timeouts, but not a 4xx auth error or a CancellationException (never retry cancellation).catch as a final fallback so the UI shows an error state after retries are exhausted.fun main() = runBlocking {
val time = measureTimeMillis {
val a = launch { delay(500); println("A") }
val b = launch { delay(500); println("B") }
}
println("Done in ~${time}ms")
}
This prints A, B, and “Done in ~0ms” - wait, why 0? Because measureTimeMillis only measures the time to launch the two coroutines (which return immediately); runBlocking then waits for them after the block. The two delay(500)s overlap, so the program finishes in ~500ms total.
Now swap delay for Thread.sleep on a single-threaded dispatcher:
runBlocking { // single thread
launch { Thread.sleep(500); println("A") }
launch { Thread.sleep(500); println("B") }
} // takes ~1000ms - they run sequentially!
Why: delay is a suspending function - it releases the thread, so both coroutines wait concurrently (~500ms total). Thread.sleep blocks the thread; on a single-threaded dispatcher the second coroutine can’t even start until the first unblocks, so the sleeps run back-to-back (~1000ms).
never use Thread.sleep (or any blocking call) inside a coroutine without moving it to an appropriate dispatcher - it blocks a pooled thread, kills concurrency, and on Main causes ANRs. Use delay for waiting, withContext(Dispatchers.IO) for unavoidable blocking work.
fun main() = runBlocking {
println("1")
launch {
println("2")
delay(100)
println("3")
}
launch {
println("4")
delay(50)
println("5")
}
println("6")
}
Output:
1
6
2
4
5
3
Why, step by step:
println("1") runs.launch schedules a coroutine but doesn’t run it yet (it’s dispatched); execution continues.launch likewise schedules.println("6") runs - we’re still in the runBlocking body, which hasn’t suspended.2, hits delay(100) and suspends. Second prints 4, hits delay(50) and suspends.5. After ~100ms the first resumes → 3.Key teaching points:
launch doesn’t run its body immediately - it dispatches it. The current coroutine keeps going until it suspends or completes, which is why 6 prints before 2.delay is non-blocking suspension, so both coroutines wait concurrently; the 50ms one finishes first (5 before 3).runBlocking keeps the main thread alive until all child coroutines complete.By default launch/async start immediately (dispatched right away). Passing start = CoroutineStart.LAZY makes the coroutine not start until you trigger it - via start(), join(), or (for async) await().
val deferred = async(start = CoroutineStart.LAZY) {
expensiveComputation()
}
// ...nothing has run yet...
if (needed) {
val result = deferred.await() // NOW it starts and we wait
}
Use cases:
The big gotcha: with lazy async, if you build multiple deferreds and only await them one-by-one, they run sequentially, not in parallel - each only starts at its await(). To parallelize lazy ones, explicitly start() them all first:
val a = async(start = LAZY) { taskA() }
val b = async(start = LAZY) { taskB() }
a.start(); b.start() // kick both off concurrently
a.await(); b.await()
Other CoroutineStart values to know:
DEFAULT - start immediately (the normal behavior).LAZY - start on demand.ATOMIC - start even if cancelled before dispatch (runs to the first suspension point).UNDISPATCHED - run in the current thread until the first suspension, skipping the dispatcher.Internals and broader design questions to study after the core material.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
flowOn(dispatcher) changes where the part of a Flow above it runs. The
collector and operators below it stay in the collector’s context.
flow { emit(readFromDisk()) } // runs on IO
.map { parse(it) } // runs on IO (upstream of flowOn)
.flowOn(Dispatchers.IO)
.map { toUiModel(it) } // runs on the collector's context
.collect { render(it) } // collector's context (e.g. Main)
For example, disk work can run on Dispatchers.IO while collect remains on
the main thread to update the UI.
Why not use withContext around emit? A flow builder expects its values
to be emitted from one consistent coroutine context. Moving only an emit call
to another context breaks that rule. Move the upstream Flow with flowOn
instead.
// ❌ throws: "Flow invariant is violated"
flow { withContext(Dispatchers.IO) { emit(load()) } }
// ✅ use flowOn instead
flow { emit(load()) }.flowOn(Dispatchers.IO)
The rule keeps threading predictable. You can read a Flow chain from bottom to top and see which part changes dispatcher.
Key points: multiple flowOns each govern the segment above them; the terminal collect runs in whatever context calls it (often Main), which is exactly what you want for updating UI.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
stateIn and shareIn convert a cold flow into a hot one so an expensive upstream (a DB query, a network poll) runs once and is shared across collectors, instead of restarting per subscriber.
stateIn → produces a StateFlow (has a current value; needs an initial value).shareIn → produces a SharedFlow (configurable replay; no current-value requirement).val uiState: StateFlow<UiState> = repository.observeData() // cold, restarts per collector
.map { it.toUiState() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Loading,
)
The started strategy controls when the upstream is active:
Eagerly - starts immediately, never stops. Wastes work if no one’s listening.Lazily - starts on the first collector, then stays forever.WhileSubscribed(stopTimeoutMillis) - active only while there’s a subscriber, and stops stopTimeout ms after the last one leaves.Why WhileSubscribed(5000) is the standard choice: on a configuration change the UI briefly unsubscribes and resubscribes. The 5-second grace period keeps the upstream alive across rotation (so you don’t re-query the DB or re-hit the network), but still stops it when the user actually navigates away and backgrounds the app - preventing leaks and wasted work.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Handle flow errors with the catch operator, not a try/catch wrapped around the chain - and never try/catch inside the flow { } builder around emit.
repository.observe()
.map { transform(it) }
.catch { e -> emit(fallbackValue) } // catches upstream errors
.onEach { render(it) }
.launchIn(viewModelScope)
Exception transparency is the design principle behind this: a flow must never catch exceptions from its downstream (the collector). A catch operator only handles exceptions from operators above it - emissions, map, the builder. An exception thrown in collect (downstream) is not caught by an upstream catch.
flow { emit(1) }
.catch { /* will NOT catch the error below - it's downstream */ }
.collect { throw RuntimeException() } // propagates to the collector's scope
Why this rule exists: it keeps error handling local and predictable. An operator can only deal with failures of the work it declares above it; the collector’s own bugs surface where the collector runs.
Practical toolkit:
catch - recover from upstream errors (emit a fallback, log, map to an error state).retry(n) / retryWhen - re-subscribe to the upstream on failure (great for flaky network flows, often with exponential backoff).onCompletion { cause -> } - runs on success and failure (cause is non-null on error) - use for cleanup, not recovery.try/catch around collect, or handle them in the coroutine’s scope.Anti-pattern: wrapping emit() in a try/catch inside flow { } - it can swallow CancellationException and breaks transparency. Use the catch operator instead.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
transform { } is the general operator behind map/filter: for each upstream value you can emit zero, one, or many downstream values.
flow.transform { value ->
emit("loading $value")
emit(fetch(value)) // emit multiple per input
}
The flatMap* family handles the case where each value maps to another flow, and they differ in how they handle concurrency of those inner flows:
flatMapConcat - process inner flows sequentially: fully collect one before starting the next. Order preserved, no overlap.flatMapMerge - collect inner flows concurrently (up to a concurrency limit), interleaving their emissions. Fastest, order not guaranteed.flatMapLatest - when a new upstream value arrives, cancel the current inner flow and switch to the new one.queries.flatMapLatest { q -> repo.search(q) } // search-as-you-type (cancel stale)
ids.flatMapMerge { id -> repo.detail(id) } // load many in parallel
events.flatMapConcat { e -> process(e) } // strict ordering, one at a time
How to choose:
flatMapConcat.flatMapMerge.flatMapLatest.Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A Channel is a coroutine-friendly queue for passing values between coroutines - a hot, stateful primitive. One coroutine sends, another receives; each element is delivered to exactly one receiver.
val channel = Channel<Int>()
launch { for (x in 1..3) channel.send(x) ; channel.close() }
launch { for (x in channel) println(x) } // 1 2 3
Channel vs Flow:
Channel; you wrap it in receiveAsFlow() / callbackFlow / use SharedFlow. Channels back callbackFlow and produce.Channel types (by buffer capacity):
RENDEZVOUS (default, 0) - send suspends until a receive is ready. Tight handoff.BUFFERED - a default-sized buffer; send only suspends when full.CONFLATED - keeps only the latest; new sends overwrite the unread value, never suspend.UNLIMITED - unbounded buffer; send never suspends (watch memory).When to use a Channel directly: producer/consumer pipelines, fan-out work distribution, or one-time events where exactly-once delivery to a single consumer matters. For state or broadcast-to-many, prefer StateFlow/SharedFlow.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Use callbackFlow to bridge a listener/callback API (location updates, Firebase listeners, sensor events) into a cold Flow.
fun locationUpdates(client: FusedLocationProviderClient): Flow<Location> = callbackFlow {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(it) } // emit into the flow
}
}
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
// REQUIRED: suspend until the collector cancels, then clean up
awaitClose { client.removeLocationUpdates(callback) }
}
The essential pieces:
trySend(value) (or send) emits from inside the callback. callbackFlow provides a channel, so emission is allowed from other threads/contexts (unlike a plain flow { }).awaitClose { } is mandatory - it keeps the flow alive while the callback is registered and runs your teardown (unregister the listener) when the collector cancels or the flow completes. Forgetting it throws and, worse, leaks the listener.callbackFlow vs channelFlow: both give you a channel-backed flow you can emit to from multiple contexts. callbackFlow is channelFlow specialized for the callback-bridging pattern (it expects an awaitClose). Use channelFlow when you need concurrent emission from multiple coroutines.
Why not flow { }? A plain flow { } enforces context preservation and can’t emit from a callback on another thread - callbackFlow exists precisely to handle that case safely.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
suspendCancellableCoroutine converts a single-shot callback API into a suspend function. It suspends the coroutine and hands you a Continuation to resume when the callback fires.
suspend fun FusedLocationProviderClient.awaitLocation(): Location =
suspendCancellableCoroutine { cont ->
val task = lastLocation
task.addOnSuccessListener { location ->
cont.resume(location) // resume with result
}
task.addOnFailureListener { e ->
cont.resumeWithException(e) // resume by throwing
}
// Clean up if the coroutine is cancelled while waiting
cont.invokeOnCancellation { /* cancel the task */ }
}
The contract:
resume(value) exactly once on success, or resumeWithException(e) on failure. Calling twice throws.invokeOnCancellation { } lets you cancel the underlying operation if the coroutine is cancelled while suspended - this is why you use the Cancellable variant over plain suspendCoroutine.suspendCancellableCoroutine vs callbackFlow:
suspendCancellableCoroutine → one value (a single async result). Like awaiting a Task/Future.callbackFlow → a stream of values from a listener over time.Real uses: awaiting a Play Services Task, a one-time AsyncLayoutInflater, an old listener-based SDK call, or bridging Java Future/Call into suspend. Many libraries already provide await() extensions built on exactly this.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Coroutines run concurrently, so shared mutable state still needs protection - but you should avoid blocking locks.
Don’t use synchronized / ReentrantLock around suspending code: they block the thread, defeating coroutines, and you can’t suspend while holding them.
Options, best-first:
1. Avoid shared state. The cleanest fix - confine state to a single coroutine, or use immutable data + StateFlow.update { } (atomic, lock-free):
_state.update { it.copy(count = it.count + 1) } // atomic compare-and-set
2. Mutex - a coroutine-aware lock that suspends instead of blocking:
val mutex = Mutex()
suspend fun increment() = mutex.withLock { counter++ }
3. Confine to a single-threaded dispatcher - withContext(singleThreadDispatcher) or Dispatchers.Default.limitedParallelism(1) serializes access without a lock.
4. Atomics (AtomicInteger, atomicfu) for simple counters.
What to remember:
Mutex.withLock is the coroutine equivalent of synchronized, but it suspends - no thread blocked.Mutex is not reentrant (locking it twice in the same coroutine deadlocks), unlike synchronized.StateFlow.update {} over any lock - it’s atomic and idiomatic.Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
fun main() = runBlocking {
val deferred = async {
throw RuntimeException("boom")
}
delay(100)
println("after delay")
// note: we never call deferred.await()
}
Output: the program crashes - RuntimeException: boom propagates and "after delay" does not print.
Why this surprises people: they expect that because async “stores” its exception for await(), not awaiting means the exception is harmless. But here async is a child of runBlocking, whose context has a regular Job. When the child fails, structured concurrency propagates the failure to the parent, cancelling it - independent of whether you ever call await(). So the whole runBlocking fails.
The “exception is deferred to await()” rule only describes where you can catch it; it does not stop the failure from propagating up the Job hierarchy and cancelling the parent.
To actually isolate it, give the async a supervisor parent so its failure doesn’t cancel the parent:
supervisorScope {
val d = async { throw RuntimeException("boom") }
delay(100)
println("after delay") // now prints
// exception only surfaces if/when you await d
}
Lesson: under a normal Job, an unhandled async failure still tears down the scope. Use supervisorScope/SupervisorJob for independent children, and remember await() is where you observe the exception, not what triggers propagation.