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
01What is a coroutine, and how does suspend differ from blocking a thread?
junior
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
}
- Suspension ≠ blocking —
delayhands control back to the dispatcher;Thread.sleepholds the thread hostage. - Lightweight — a coroutine is a heap object (continuation + state), not a ~1 MB stack like a thread.
- 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.
02How does suspend work under the hood?
senior
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
}
// ...
}
- CPS — the real bytecode signature is
login(String, Continuation<Token>): Any?— it returns the result (if it never suspended) orCOROUTINE_SUSPENDED. - State machine — a single continuation object with a
labelkeeps local variables between states. - Resumption — the layer below calls
cont.resumeWith(result), and the machine jumps to the nextwhenbranch.
⚠️ 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.
03Dispatchers Main, IO, Default, and Unconfined — what runs where?
junior
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 |
- 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. - IO is not about speed — extra threads do not make CPU work faster; they exist so many coroutines can sit in blocking calls simultaneously.
- 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.
04Structured concurrency: what does the coroutine hierarchy guarantee?
middle
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
- The parent waits for children —
coroutineScopecompletes only after everyasync/launchinside it: no forgotten background stragglers. - Cancellation flows down — cancel the scope (the user leaves the screen) and the whole tree is cancelled, including nested coroutines at any depth.
- Failures flow up — a failing child cancels its siblings and hands the exception to the parent.
- 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.
05Job vs SupervisorJob — and why doesn't launch(SupervisorJob()) work the way people expect?
middle
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)
- 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.
- Why the trick fails — a Job passed into
launch's context becomes the parent of that coroutine. Inside it,launchcreates regular child Jobs, and supervisor semantics do not extend to them. - 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.
06Exceptions in coroutines: launch vs async, and where does CoroutineExceptionHandler actually work?
senior
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) }
- 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.
- async — deferred: without
await()you may never see the exception at all (though with a regular Job it still cancels the parent). - 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.
07viewModelScope, lifecycleScope, repeatOnLifecycle: how do you collect a Flow from the UI correctly?
middle
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) }
}
}
- viewModelScope — SupervisorJob + Main.immediate, cancelled in
onCleared(): work lives as long as the ViewModel does and survives screen rotation. - 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.
- 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.
- Compose —
collectAsStateWithLifecycle()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.
08withContext vs async/await: when is each appropriate?
junior
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
}
- withContext — adds no concurrency: the same coroutine keeps executing, just in a different context; when the block returns, so does the result.
- async — returns a
Deferred; it earns its keep once there are at least two independent launches. - 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.
09Cold vs hot Flow: what's the difference and which are which?
middle
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,
)
- 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.
- 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.
11Implement search-as-you-type with Flow operators.
middle
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)
- 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.
- 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.
- 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.
12Why does a coroutine refuse to cancel, and how do you fix it?
senior
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
}
- Suspension points — delay, yield, await, and all kotlinx.coroutines suspend functions check the flag and throw CancellationException.
- isActive / ensureActive / yield — isActive as the loop condition; ensureActive throws by itself; yield additionally gives up the thread.
- 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.