← All topics
Kotlin

Coroutines & Flow

Structured concurrency, scopes, dispatchers, cancellation, exception handling, and reactive streams with Flow, StateFlow, and Channels.

41 questions ★ 20 high priority 10 junior22 mid9 senior

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.

A simple study path

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.

What gets tested

  • Fundamentals - what suspend really does (CPS / state machine), coroutines vs threads, builders (launch, async, withContext, runBlocking).
  • Structured concurrency - scopes, Job vs SupervisorJob, parent/child cancellation, coroutineScope vs supervisorScope.
  • Dispatchers & context - Main/IO/Default, CoroutineContext elements, withContext, limitedParallelism.
  • Cancellation - cooperative cancellation, isActive/ensureActive, CancellationException, timeouts.
  • Exception handling - where launch vs async exceptions surface, CoroutineExceptionHandler, supervision.
  • Flow - cold vs hot, operators (map, flatMapLatest, combine, debounce), flowOn, backpressure, catch/retry.
  • StateFlow / SharedFlow / Channels - modeling UI state vs one-off events, stateIn/shareIn, WhileSubscribed.
  • Android integration - viewModelScope, lifecycleScope, repeatOnLifecycle, collectAsStateWithLifecycle.
  • Testing - runTest, test dispatchers, Turbine.

How interviewers ask

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.

Visual primer

How coroutines execute

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.

01

Scope

Owns the coroutine. Cancelling the scope cancels its unfinished children.

02

Dispatcher

Chooses which thread or thread pool is available to run the work.

03

Suspend

Pauses the coroutine, not necessarily the thread. delay is the classic example.

04

Job

Tracks the work so it can be joined, cancelled, or checked for completion.

The mental model

Pause the task, free the thread

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.

Run Suspend Thread runs other work Resume
Example 1

Launching one child

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")
}
Execution flow Same scope
  1. Parent1: parent starts
  2. Parent2: parent keeps going
  3. Child3: child starts
  4. Childdelay(100)suspends
  5. Child4: child resumes
  6. Parent5: parent resumes
Output 1 → 2 → 3 → 4 → 5
Example 2

Launching two children

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.

Timeline Concurrent work
Output start → A1 → B1 → B2 → A2 → end
Structured concurrency

Children belong to a parent

When the ViewModel is cleared, its scope cancels both children. The parent also waits for its children before it completes.

Flow

Values move through a pipeline

flowOf(1, 2, 3, 4)
    .map { it * 10 }
    .filter { it >= 30 }
    .collect { println(it) }
Emit1, 2, 3, 4
map × 1010, 20, 30, 40
filter ≥ 3030, 40
Collect30, 40

A cold Flow does nothing until it is collected. Each operator handles a value and passes its result to the next step.

Start here

Core ideas you should be able to explain in plain language.

Coroutine foundations

How are coroutines different from threads? Why are they called 'lightweight'?
Junior #coroutines#threads#fundamentals

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:

  • Cheap. You can launch hundreds of thousands of coroutines; they’re objects, not OS threads. Many coroutines share a small pool of threads.
  • Suspend, don’t block. A coroutine waiting on I/O suspends and frees its thread for other coroutines. A blocked thread sits idle holding its stack.
  • Structured. Coroutines form parent/child scopes with automatic cancellation and error propagation - threads have none of that.
  • Cooperative scheduling at suspension points, vs. preemptive OS thread scheduling.
// 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.”

What is a suspend function, and how does suspension actually work under the hood?
Junior #coroutines#suspend#internals

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:

  • State 0: call auth(continuation), save label = 1, return COROUTINE_SUSPENDED.
  • When auth completes, it invokes the continuation → re-enters at state 1, and so on.

Why this matters:

  • Suspension frees the thread to do other work - that’s how thousands of coroutines run on a small pool. A blocked thread sits idle; a suspended coroutine doesn’t hold a thread.
  • 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.”
  • There’s no magic threading; it’s compiler-generated callbacks that look like sequential code.
What's the difference between launch and async in coroutines?
Junior #coroutines#concurrency

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().

Explain the coroutine dispatchers: Main, IO, Default, Unconfined. When do you use each?
Junior #coroutines#dispatchers#threading

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:

  • IO vs Default is the most-asked distinction: IO for waiting (blocking calls), Default for computing. They actually share threads, but IO permits many more concurrent blocking ops.
  • Main-safe suspending APIs such as Retrofit’s suspend support and Room’s suspend queries already keep blocking work off Main; don’t add an IO hop by reflex. Check the API contract.
  • 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).
What does runBlocking do, and when should (and shouldn't) you use it?
Junior #coroutines#runBlocking#testing

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.
  • Tests - though runTest is now preferred for coroutine tests (it skips delays and controls virtual time).
  • Bridging a suspend function into a legacy blocking API you must implement.

Where it’s dangerous:

  • Never on the main/UI thread in an app - it blocks the thread, defeating the entire point of coroutines and risking ANRs. This is the #1 misuse interviewers watch for.
  • Inside another coroutine - you’d block a pool thread instead of suspending. Use 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.

Scope ownership

What are viewModelScope and lifecycleScope? When is each cancelled?
Junior #coroutines#scopes#lifecycle#android

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:

  • Don’t run data work in 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.
  • For collecting flows in the UI, pair lifecycleScope with repeatOnLifecycle(STARTED) so collection pauses in the background.

Flow foundations

What are the main ways to create a Flow?
Junior #flow#builders

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.

When should an API return a suspend value, a Flow, or a Sequence?
Junior #coroutines#flow#suspend#sequence#api-design

Choose based on how many values arrive and whether producing them may suspend.

API shapeValuesCan suspend between values?Typical use
suspend fun load(): Userone resultyesone network/database operation
fun observe(): Flow<User>zero to many over timeyesdatabase updates, UI state, events
fun parse(): Sequence<Row>many, pulled synchronouslynolazy 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.

What are terminal operators on a Flow, and why does nothing happen without one?
Junior #flow#terminal-operators#cold

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.

When would you use StateFlow over LiveData?
Junior #flow#state#lifecycle

StateFlow and LiveData are both observable, lifecycle-friendly state holders, but StateFlow is the modern default in a coroutine-first codebase.

LiveDataStateFlow
Always has a valueNo; it may be unsetYes (requires initial value)
Lifecycle-awareBuilt inVia repeatOnLifecycle / collectAsStateWithLifecycle
OperatorsFew (map, switchMap)Full Flow operator set
ThreadingMain-thread boundAny 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.

Use it in practice

Common implementation choices, debugging, and trade-offs.

Coroutine foundations

What is CoroutineContext, and what are its elements?
Mid #coroutines#context

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):

  • A child coroutine inherits the parent’s context, then applies any overrides you pass to the builder.
  • The child always gets a new 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.
withContext vs launch vs async - when do you use each?
Mid #coroutines#builders#withContext

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.

Scope ownership

Explain structured concurrency. Why does it matter on Android?
Mid #coroutines#scopes#cancellation

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:

  1. No leaks. A coroutine can’t outlive its scope. When the scope is cancelled, all children are cancelled.
  2. Cancellation propagates. Cancelling a parent cancels its children; a failing child (by default) cancels its siblings and parent.
  3. Errors aren’t lost. Exceptions surface to the scope rather than vanishing on some detached thread.

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.

How do you run multiple independent suspend calls in parallel and combine the results?
Mid #coroutines#async#practical#parallel

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:

  • Accidentally sequential - async { a() }.await() then async { b() }.await() runs them one after another. Start all the asyncs first, then await.
  • Wanting independent failures - if one call failing should not cancel the others, use 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()
}
How does coroutine cancellation work? Why is it 'cooperative'?
Mid #coroutines#cancellation

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.
  • Call any cancellable suspend function (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.
  • To run cleanup that itself suspends (closing a resource), use 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.
How do you add a timeout to a coroutine? withTimeout vs withTimeoutOrNull.
Mid #coroutines#timeout#cancellation

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.
  • The timeout cancels the block, but cleanup in finally still runs. If cleanup suspends, wrap it in withContext(NonCancellable).
  • For a non-cancelling timeout (let the work finish but stop waiting), race it with select/a separate delay instead.
How do exceptions behave in launch and async coroutines?
Mid #coroutines#error-handling#exceptions

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.
  • A 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.
  • With a regular Job, one child’s exception cancels siblings; with SupervisorJob/supervisorScope, children fail independently and each needs its own handling.
Job vs SupervisorJob, and coroutineScope vs supervisorScope?
Mid #coroutines#job#supervision#error-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:

  1. With 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.
  2. Putting 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.
Why is GlobalScope considered an anti-pattern?
Mid #coroutines#globalscope#structured-concurrency

GlobalScope launches coroutines that live for the entire application lifetime and belong to no parent. That breaks structured concurrency and causes real problems:

  • Leaks - the coroutine isn’t tied to any lifecycle, so it keeps running after the screen (and the objects it references) is gone. A GlobalScope.launch capturing a ViewModel or Context leaks it.
  • No cancellation - nothing cancels it. You can’t stop it when the user navigates away; it runs to completion regardless.
  • Orphaned errors - exceptions don’t propagate to any parent scope, so failures can vanish or crash unexpectedly.
  • Hard to test - there’s no scope to control or wait on in tests.
// ❌ 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.

Flow foundations

What is the difference between a cold Flow and a hot Flow?
Mid #flow#cold-hot#stateflow#sharedflow

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 whencollectedexists independently
Per-collector executionyesshared
Has a current valuenoStateFlow: yes / SharedFlow: optional replay
Use forone-shot data, transformationsobservable 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.

Flow operators

What do onStart, onEach, onCompletion, and onEmpty do on a Flow?
Mid #flow#operators#lifecycle

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)
collect vs collectLatest, and what do flatMapLatest / mapLatest do?
Mid #flow#collectLatest#flatMapLatest

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:

  • Only the latest value matters (UI state, search results) → collectLatest / flatMapLatest.
  • Every value must be processed (analytics events, a write queue) → plain 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.

combine vs zip for Flows - what's the difference?
Mid #flow#combine#zip#operators

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).
How does Flow handle backpressure? Explain buffer, conflate, and collectLatest.
Mid #flow#backpressure#buffer#conflate

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:

  • Need every value, just want concurrency → buffer.
  • Only the newest value matters, dropping intermediates is fine → conflate.
  • Only the newest matters and processing the old one should be cancelled → collectLatest.

buffer also accepts an onBufferOverflow policy (SUSPEND, DROP_OLDEST, DROP_LATEST) for fine control.

State and lifecycle

StateFlow vs SharedFlow - how do you choose, and how do you model one-time events?
Mid #flow#stateflow#sharedflow#events

Use StateFlow for state and SharedFlow for broadcasts or events.

StateFlow always has a current value. A new collector immediately receives that value, which makes it a natural fit for a screen’s loading, content, and error state.

private val _state = MutableStateFlow(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
_state.value = UiState.Loaded(items)   // synchronous, has a current value

SharedFlow does not need to hold one current value. You can configure whether it replays old values and how it buffers new ones. That makes it useful when several collectors need the same stream of events.

private val _events = MutableSharedFlow<Event>()   // replay = 0 by default
val events: SharedFlow<Event> = _events.asSharedFlow()
suspend fun navigate() = _events.emit(Event.GoToDetail)

Choosing:

  • State that the screen renders (loading/content/error) → StateFlow.
  • Transient broadcasts for active collectors (analytics ticks, refresh signals, a snackbar that may be dropped while the UI is absent) → SharedFlow with replay = 0.

Why not put consumable events directly in StateFlow? It retains the last value and replays it, so a naïve snackbar or navigation value may run again after recreation. SharedFlow(replay = 0) avoids replay, but it can drop an event when there is no collector. If delivery must survive the UI stopping, model the outcome as durable UI state (often the best option) or use an explicit queued-event design. State vs event is a delivery-semantics decision, not just a type choice.

Optional details:

  • MutableStateFlow.value updates are conflated - fast intermediate values can be skipped; a rapidly emitting StateFlow won’t deliver every value, only the latest.
  • Equality matters: StateFlow skips emissions that are equals to the current - using a data class for state means copy()-ing is what makes it emit.
  • SharedFlow.emit suspends if the buffer is full; tryEmit doesn’t.
Why collect a Flow with repeatOnLifecycle? What problem does it solve?
Mid #flow#lifecycle#repeatOnLifecycle#android

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.
  • Compose: 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.

Show the idiomatic way to expose state and handle events from a ViewModel with Flow.
Mid #coroutines#flow#practical#viewmodel#udf

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.
  • State vs event split - render-able state goes in 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.

Practical Flow pipelines

Implement search-as-you-type with Flow. Which operators do you use and why?
Mid #flow#practical#debounce#flatMapLatest

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:

  1. debounce(300) - don’t fire a request on every keystroke; wait until the user pauses. Saves network calls.
  2. filter - skip 0–1 character queries that aren’t worth searching.
  3. distinctUntilChanged - if the debounced query equals the last one (e.g. type then backspace), don’t repeat the search.
  4. 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.

Flow sharing and reliability

How do you retry a failing Flow with exponential backoff?
Mid #flow#retry#practical#error-handling

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.
  • Only retry transient failures. Check the cause - retry IOException/timeouts, but not a 4xx auth error or a CancellationException (never retry cancellation).
  • Pair with catch as a final fallback so the UI shows an error state after retries are exhausted.
  • Add jitter (a small random offset) in production to avoid thundering-herd retries.

Concurrency code reasoning

What changes when a coroutine uses delay instead of Thread.sleep?
Mid #coroutines#output-based#delay#blocking
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.

In what order do launched coroutines run when they call delay?
Mid #coroutines#output-based#concurrency
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:

  1. println("1") runs.
  2. The first launch schedules a coroutine but doesn’t run it yet (it’s dispatched); execution continues.
  3. The second launch likewise schedules.
  4. println("6") runs - we’re still in the runBlocking body, which hasn’t suspended.
  5. Now the body finishes its synchronous part; the launched coroutines run. First coroutine prints 2, hits delay(100) and suspends. Second prints 4, hits delay(50) and suspends.
  6. After ~50ms the second resumes → 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.
What is CoroutineStart.LAZY, and how does a lazy async behave?
Mid #coroutines#async#lazy

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:

  • Defer expensive work you might not need.
  • Set up several coroutines and start them at a controlled moment.

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.

Optional deep dives

Internals and broader design questions to study after the core material.

Flow operators

What does flowOn do, and why is Flow context preservation important?
Senior #flow#flowOn#context#threading

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.

Flow sharing and reliability

What do stateIn and shareIn do, and why use SharingStarted.WhileSubscribed?
Senior #flow#stateIn#shareIn#sharing

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.

How do you handle errors in a Flow?
Senior #flow#error-handling#catch

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.
  • For the collector’s errors, use a normal 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.

What does the transform operator do, and how do flatMapConcat, flatMapMerge, and flatMapLatest differ?
Senior #flow#operators#flatMap#transform

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:

  • Need ordering, one-at-a-time → flatMapConcat.
  • Need throughput, order doesn’t matter → flatMapMerge.
  • Only the latest input matters, cancel the rest → flatMapLatest.

Channels and callback interop

What is a Channel, how does it differ from a Flow, and what are the channel types?
Senior #coroutines#channels#flow

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:

  • A Flow is a cold recipe - declarative, re-runs per collector, no buffering by itself.
  • A Channel is hot communication - values exist whether or not anyone reads, and each value goes to one consumer (not broadcast).
  • In practice you rarely expose a raw 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.

How do you convert a callback-based API into a Flow? (callbackFlow / channelFlow)
Senior #flow#callbackflow#interop

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.

What is suspendCancellableCoroutine and when do you use it?
Senior #coroutines#interop#suspendCancellableCoroutine

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:

  • Call 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:

  • suspendCancellableCoroutineone 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.

Shared-state safety

How do you protect shared mutable state in coroutines? Mutex vs synchronized.
Senior #coroutines#concurrency#mutex

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.
  • For UI state, prefer StateFlow.update {} over any lock - it’s atomic and idiomatic.

Concurrency code reasoning

What happens when async fails and its result is never awaited?
Senior #coroutines#output-based#exceptions#async

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.