Skip to content
Mobile & design

12 Android Coroutines and Flow Interview Questions and Answers

This focused guide turns RecallDeck’s curated Android Coroutines and Flow material into 12 interview-ready questions. Answer each one before opening the explanation, then use the examples and edge cases to repair anything vague or incomplete.

12 min read12 detailed answersReviewed Aug 24, 2026
What to remember

Tie implementation or design choices to platform behavior, user feedback, lifecycle, accessibility, and the trade-off the team must maintain.

Question set

12 detailed answers

01

What is a coroutine, and how does suspend differ from blocking a thread?

Short answer: A coroutine is a lightweight unit of concurrency on top of threads. Suspension parks the coroutine and frees the thread for other work; blocking keeps the thread busy. That is why thousands of coroutines can live on a single thread.

In depth:

// Blocking: the thread is busy and useless
fun blocking() {
    Thread.sleep(1000)   // the thread sleeps — unavailable to anyone
}

// Suspension: the coroutine parks, the thread stays free
suspend fun suspending() {
    delay(1000)          // meanwhile the thread runs other coroutines
}

fun main() = runBlocking {
    repeat(100_000) { launch { delay(1000) } }  // fine even on one thread
    // 100,000 threads with Thread.sleep(1000) — OutOfMemoryError
}
  1. Suspension ≠ blockingdelay hands control back to the dispatcher; Thread.sleep holds the thread hostage.
  2. Lightweight — a coroutine is a heap object (continuation + state), not a ~1 MB stack like a thread.
  3. Cooperative — a coroutine yields its thread only at suspension points.

⚠️ Common mistake: saying a suspend function runs on a background thread. It does not: suspend by itself never switches threads — the dispatcher decides where the code runs.

02

How does suspend work under the hood?

Short answer: Via CPS (continuation-passing style) and a state machine. The compiler adds a hidden Continuation parameter to the function, slices its body into states at suspension points, and returns COROUTINE_SUSPENDED when it suspends. Resumption is a resumeWith call that re-enters the function at the right label.

In depth:

suspend fun login(user: String): Token {
    val id = authorize(user)    // suspension point 1
    return fetchToken(id)       // suspension point 2
}

// a sketch of what the compiler generates:
fun login(user: String, cont: Continuation<Token>): Any? {
    val sm = cont as? LoginSM ?: LoginSM(cont)
    when (sm.label) {
        0 -> {
            sm.label = 1
            val r = authorize(user, sm)   // sm is passed as the callback
            if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        }
        1 -> { /* we got resumed: the result is in sm.result */ }
        // ...state 2 — for fetchToken
    }
    // ...
}
  1. CPS — the real bytecode signature is login(String, Continuation<Token>): Any? — it returns the result (if it never suspended) or COROUTINE_SUSPENDED.
  2. State machine — a single continuation object with a label keeps local variables between states.
  3. Resumption — the layer below calls cont.resumeWith(result), and the machine jumps to the next when branch.

⚠️ Common mistake: treating suspend as thread magic. There are no threads in this story at all: it is a pure compiler transformation; the thread is chosen by the dispatcher via ContinuationInterceptor.

03

Dispatchers Main, IO, Default, and Unconfined — what runs where?

Short answer: Default is a pool sized to the number of CPU cores, for computation. IO is an elastic pool (up to 64 threads by default) for blocking I/O. Main is the single main-looper thread — all UI. Unconfined is not a pool at all: it resumes on whatever thread the suspension finished on.

In depth:

Dispatcher Threads What for
Dispatchers.Main main looper UI, main-thread state
Dispatchers.Default = CPU cores CPU-bound: parsing, sorting, diffing
Dispatchers.IO elastic, up to 64+ blocking I/O: files, network, Room
Dispatchers.Unconfined none of its own starts on the current thread, resumes wherever
  1. IO and Default share threads — they are one common pool with different limits, so withContext(Dispatchers.IO) called from Default may not switch threads at all — only the limit changes.
  2. IO is not about speed — extra threads do not make CPU work faster; they exist so many coroutines can sit in blocking calls simultaneously.
  3. Unconfined — a tool for tests and rare low-level optimizations, not for production logic.

⚠️ Common mistake: thinking IO makes code parallel. A dispatcher only says where code runs; parallelism comes from launch/async. And CPU work on IO just means more threads fighting over the same cores.

04

Structured concurrency: what does the coroutine hierarchy guarantee?

Short answer: Every coroutine lives inside a parent Job and cannot outlive it: the parent waits for all children to finish, cancellation flows down, failures flow up. There are no orphaned coroutines — the scope draws the lifecycle boundary of the work.

In depth:

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val user = async { api.fetchUser() }
    val feed = async { api.fetchFeed() }
    Dashboard(user.await(), feed.await())
}   // does not return while children are alive;
    // fetchFeed fails — fetchUser gets cancelled, the error goes to the caller
  1. The parent waits for childrencoroutineScope completes only after every async/launch inside it: no forgotten background stragglers.
  2. Cancellation flows down — cancel the scope (the user leaves the screen) and the whole tree is cancelled, including nested coroutines at any depth.
  3. Failures flow up — a failing child cancels its siblings and hands the exception to the parent.
  4. Scope = lifecycle boundary — viewModelScope, lifecycleScope: work is tied to its owner, coroutine leaks are ruled out by design, not by discipline.

⚠️ Common mistake: reaching for GlobalScope or a homemade CoroutineScope(Dispatchers.IO) with no owner so the work definitely finishes. That is exactly an escape from the structure: nobody will ever cancel or await those coroutines.

05

Job vs SupervisorJob — and why doesn't launch(SupervisorJob()) work the way people expect?

Short answer: With a regular Job, a child's failure cancels the parent and all siblings; SupervisorJob suppresses that propagation — a failing child leaves the others alone. The trap: launch(SupervisorJob()) makes the SupervisorJob the parent of that one coroutine only, while its own children are still linked with regular Jobs.

In depth:

// ❌ looks like protection, does nothing
scope.launch(SupervisorJob()) {
    launch { throw IOException() }  // children get regular Jobs!
    launch { /* gets cancelled anyway */ }
}

// ✓ supervisorScope — children are truly independent
supervisorScope {
    launch { throw IOException() }  // one fails
    launch { /* keeps running */ }
}

// ✓ a scope built with a supervisor — like viewModelScope
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
  1. Why a supervisor at all — for the level where one task's failure must not take down the rest: a screen scope, an app scope, independent parallel loads.
  2. Why the trick fails — a Job passed into launch's context becomes the parent of that coroutine. Inside it, launch creates regular child Jobs, and supervisor semantics do not extend to them.
  3. viewModelScope — is already built on a SupervisorJob: one crashed coroutine does not kill the whole ViewModel scope.

⚠️ Common mistake: sprinkling SupervisorJob() into launch/async contexts as a crash amulet. Only supervisorScope { } or a scope originally built with a SupervisorJob actually work.

06

Exceptions in coroutines: launch vs async, and where does CoroutineExceptionHandler actually work?

Short answer: launch propagates an exception immediately up the Job tree; async stores it and throws at await(). CoroutineExceptionHandler fires only on root coroutines (in the scope's context or a root launch) — installing it on children is useless: the exception has already gone to the parent.

In depth:

val handler = CoroutineExceptionHandler { _, e -> log(e) }
val scope = CoroutineScope(SupervisorJob() + handler)

scope.launch { throw IOException() }         // handler catches it
scope.launch {
    launch(handler) { throw IOException() }  // handler IGNORED: not a root
}

val d = scope.async { throw IOException() }  // silent, until...
d.await()                                    // ...someone calls await

// never swallow cancellation:
try { work() }
catch (e: CancellationException) { throw e }  // rethrowing is mandatory
catch (e: Exception) { log(e) }
  1. launch — fail-fast: the exception goes up immediately; with a regular Job it takes down the whole scope, with a SupervisorJob it reaches the CEH.
  2. async — deferred: without await() you may never see the exception at all (though with a regular Job it still cancels the parent).
  3. CEH — the last line of defense instead of a process crash; it works exactly where the exception has nowhere left to bubble.

⚠️ Common mistake: a catch (e: Exception) in a loop that swallows CancellationException — the coroutine stops being cancellable. Always rethrow cancellation.

07

viewModelScope, lifecycleScope, repeatOnLifecycle: how do you collect a Flow from the UI correctly?

Short answer: In the ViewModel — viewModelScope: cancelled in onCleared. In the UI, collect Flows via lifecycleScope.launch { repeatOnLifecycle(STARTED) { ... } }: collection starts on onStart and is fully cancelled on onStop, not merely paused.

In depth:

// the canonical state-collection pattern in a fragment/activity
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}
  1. viewModelScope — SupervisorJob + Main.immediate, cancelled in onCleared(): work lives as long as the ViewModel does and survives screen rotation.
  2. repeatOnLifecycle(STARTED) — relaunches the block on every onStart and cancels it on onStop. While the app is backgrounded the producer does no work: GPS, database, network are all released.
  3. What is wrong with launchWhenStarted — it only SUSPENDS the collector while the upstream keeps running in the background; that is exactly why it was deprecated.
  4. ComposecollectAsStateWithLifecycle() does the same in one line.

⚠️ Common mistake: lifecycleScope.launch { flow.collect { } } without repeatOnLifecycle: collection keeps going even with the app in the background, and with a hot Flow that is wasted work all the way to destroy.

08

withContext vs async/await: when is each appropriate?

Short answer: withContext is a sequential context switch: run a block on another dispatcher and return its result. async/await is about concurrency: start several pieces of work in parallel and wait for all of them. async for a single call is the classic antipattern.

In depth:

// sequential: just get off the main thread
suspend fun loadUser(): User = withContext(Dispatchers.IO) {
    api.fetchUser()   // the result is returned to the caller
}

// parallel: two requests at once
suspend fun loadScreen(): Screen = coroutineScope {
    val user = async { api.fetchUser() }
    val posts = async { api.fetchPosts() }
    Screen(user.await(), posts.await())   // ~the time of the slowest request
}
  1. withContext — adds no concurrency: the same coroutine keeps executing, just in a different context; when the block returns, so does the result.
  2. async — returns a Deferred; it earns its keep once there are at least two independent launches.
  3. async { }.await() immediately — semantically the same as withContext, just with an extra coroutine and extra noise.

⚠️ Common mistake: wrapping every repository call in async { }.await() for asynchrony. Asynchrony is already provided by suspend; async exists for parallelism.

09

Cold vs hot Flow: what's the difference and which are which?

Short answer: A cold Flow is a recipe: the producer code runs anew for every collector (flow { }, Room/Retrofit streams). A hot one lives independently of subscribers and shares emissions among them: StateFlow and SharedFlow. Cold becomes hot via stateIn/shareIn.

In depth:

Cold Hot
Producer starts on every collect runs independently
Subscribers each gets its own stream share one stream
With no collectors nothing happens may emit into the void
Examples flow {}, flowOf, Room StateFlow, SharedFlow
val uiState: StateFlow<UiState> = repository.observeUser()  // cold
    .map(::toUiState)
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading,
    )
  1. WhileSubscribed(5000) — the upstream stops 5 s after the last subscriber leaves: a screen rotation (recreation takes well under 5 s) survives without restarting the upstream, while a real trip to the background genuinely stops it.
  2. stateIn vs shareIn — stateIn always holds a current value (state); shareIn is tuned via replay (events, shared streams).

⚠️ Common mistake: collecting a cold Flow with a network call inside from two places and being surprised by two requests — every collector restarts the producer.

10

StateFlow vs SharedFlow vs LiveData: which for state, which for events?

Short answer: StateFlow is for screen state: it always has a value, emissions are conflated, and duplicates are filtered as with distinctUntilChanged. SharedFlow is for one-shot events: configurable replay and buffer, no current value. LiveData is lifecycle-aware legacy: in new code its place is taken by StateFlow + repeatOnLifecycle.

In depth:

StateFlow SharedFlow LiveData
Current value always (value) none (replay optional) yes, may be empty
Conflation / dedup yes, distinct no no dedup
What for UI state events: toast, navigation legacy state
Lifecycle via repeatOnLifecycle same built in
  1. State — what to show right now: losing intermediate values is safe, only the latest matters → StateFlow.
  2. Events — do this exactly once: must not be lost, must not repeat → MutableSharedFlow(replay = 0, extraBufferCapacity = 1) or a Channel. Caveat: with no active subscriber, an event from a replay-0 SharedFlow simply vanishes — hence the whole events-vs-state debate.
  3. LiveData — a marker of the codebase's age; migration is nearly mechanical: stateIn ↔ asLiveData().

⚠️ Common mistake: putting a show-a-toast event into StateFlow: after a rotation the new collector receives the latest value — and the toast shows a second time.

11

Implement search-as-you-type with Flow operators.

Short answer: debounce so the network is not hit on every keystroke; distinctUntilChanged so the same query is not repeated; flatMapLatest so new input cancels the previous request; catch so an error does not kill the chain.

In depth:

val results: StateFlow<SearchState> = queryFlow   // MutableStateFlow from the input field
    .debounce(300)                 // wait for a pause in typing
    .distinctUntilChanged()        // do not re-search the same text
    .flatMapLatest { q ->
        flow { emit(repo.search(q)) }
            .map<List<Item>, SearchState> { SearchState.Data(it) }
            .onStart { emit(SearchState.Loading) }
            .catch { emit(SearchState.Error(it)) }
    }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Idle)
  1. flatMapLatest — on every new emission it cancels the previous inner flow together with its network request: a stale response physically cannot arrive on top of a fresh one.
  2. Why not flatMapMerge/Concat — merge runs requests in parallel and responses arrive interleaved (an old one can overwrite a new one); concat dutifully waits for each — a queue of already-stale requests builds up.
  3. catch inside flatMapLatest — one request's failure becomes an error state instead of the death of the whole search stream.

⚠️ Common mistake: putting catch outside flatMapLatest: the first network error completes the entire chain — search is dead until the screen is recreated.

12

Why does a coroutine refuse to cancel, and how do you fix it?

Short answer: Cancellation is cooperative: cancel() only sets a flag, and CancellationException is thrown at suspension points. A CPU-bound loop with no yield()/ensureActive()/isActive never notices the flag and crunches to the end. For must-finish cleanup — withContext(NonCancellable).

In depth:

val job = scope.launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000_000) {   // ❌ not a single suspension point
        crunch(i++)                // cancel() changes nothing
    }
}
job.cancel()   // and the coroutine keeps running

// ✓ the cooperative version
while (isActive && i < 1_000_000_000) { crunch(i++) }
// or ensureActive() / yield() inside the loop body

// ✓ suspending cleanup after cancellation
try { work() } finally {
    withContext(NonCancellable) { connection.close() }  // suspend calls in finally
}
  1. Suspension points — delay, yield, await, and all kotlinx.coroutines suspend functions check the flag and throw CancellationException.
  2. isActive / ensureActive / yield — isActive as the loop condition; ensureActive throws by itself; yield additionally gives up the thread.
  3. NonCancellable — in an already-cancelled coroutine any suspension point immediately throws cancellation again, so suspending cleanup in finally must live inside withContext(NonCancellable).

⚠️ Common mistake: treating cancel() as killing a thread. It is a request: code that never suspends and never checks the flag does not cancel at all.

Source notes

References and review policy

RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going

RecallDeck Interview Library

Detailed answers from the same curated interview deck, organized for search, study, and durable recall.

RSS