Engineering Interviews
Kotlin coroutines interview questions senior Android loops ask
Ten coroutines and Flow questions senior Android loops ask, what each one tests, the follow-up that decides the score, and four short bugs worth practising until you can fix them out loud.
7 min read
Every Android candidate I interview says they use coroutines. Most of them do. Fewer can tell me what happens to a child coroutine when its sibling throws, or why a test with delay(5_000) finishes in milliseconds.
After 15 years in mobile and 500+ technical interviews from the hiring side, coroutines is the topic where the gap between mid-level and senior shows fastest. The API is small. The rules behind it are not.
Below are ten questions I ask, and the interviewers I work with ask, what each one tests, and the follow-up that decides the score. If you want the wider loop first, read the 5 follow-ups that decide a senior Android loop. This article stays inside coroutines and Flow and goes further down.
The ten questions at a glance
| Question | What it tests | The follow-up |
|---|---|---|
| What is structured concurrency? | Ownership of work | What happens to children when the parent fails? |
| viewModelScope or lifecycleScope? | Which lifetime owns the work | Where does a save that must finish belong? |
| How does cancellation work? | Cooperation | Why does this loop keep running? |
| Job or SupervisorJob? | Failure propagation | Does supervisorScope catch the exception? |
| Where does CoroutineExceptionHandler work? | Uncaught versus awaited | Why did the handler never fire on async? |
| What does withContext do? | Main safety | Who should switch the dispatcher? |
| Flow, StateFlow or SharedFlow? | State versus events | What happens to an event nobody collects? |
| Cold or hot? | Who starts the producer | What does stateIn with WhileSubscribed buy you? |
| What does flowOn change? | Upstream context | Where does the collector run? |
| How do you test a coroutine? | Virtual time | Why does your test pass without waiting? |
1. What is structured concurrency?
What it tests: whether you see coroutines as a tree with an owner, or as fire and forget threads.
The answer that earns it: "Every coroutine runs in a scope, and the scope's Job is the parent. A parent does not complete until its children complete. Cancelling the parent cancels every child. When a child fails with an exception, the failure goes up, cancels the parent and with it the siblings. No coroutine is orphaned, and no error disappears silently."
The follow-up: "So what is GlobalScope for?" The senior answer is that it breaks the tree on purpose. Nothing cancels it and nothing waits for it. In an Android app that almost always means a leak or lost work, and the real fix is a scope with a named owner: the ViewModel, an application scope you inject, or WorkManager.
2. viewModelScope or lifecycleScope?
What it tests: whether you choose a scope by lifetime, not by habit.
viewModelScope is cancelled in onCleared(), so it survives rotation and dies when the user leaves the screen for good. lifecycleScope is tied to the Activity or Fragment and is cancelled on destroy, which includes rotation. In a Fragment, UI collection belongs in viewLifecycleOwner.lifecycleScope, because the Fragment can outlive its view.
The follow-up: "The user taps Save and closes the screen at once. Which scope?" Neither is enough if the write must land. A candidate who says so, and moves the write to an application scope owned by the repository or to WorkManager when it must also survive the process, has answered a senior question.
3. How does cancellation work, and why is this loop still running?
What it tests: that cancellation is cooperative. cancel() sets a flag. Nothing stops until the code checks it.
// Bug: no suspension point, so cancel() is never observed
scope.launch(Dispatchers.Default) {
for (file in files) {
hashFile(file) // blocking CPU work
}
}Suspending functions from kotlinx.coroutines such as delay, withContext and yield check for cancellation and throw CancellationException. A plain CPU loop does not.
// Fix: check the flag between units of work
scope.launch(Dispatchers.Default) {
for (file in files) {
ensureActive() // throws CancellationException once cancelled
hashFile(file)
}
}The follow-up: "You need to close a file after cancellation, and closing is a suspend call." The answer is withContext(NonCancellable) inside finally, because a cancelled coroutine that calls a suspending function throws again immediately. Keep that block short. It is for cleanup, not for new work.
4. Job or SupervisorJob?
What it tests: failure propagation, and the most common misunderstanding in this whole topic.
With a regular Job, one failing child cancels the parent and every sibling. With a SupervisorJob, a child's failure stays with that child. viewModelScope uses a SupervisorJob, which is why one failed request does not kill every other coroutine in the ViewModel.
// Bug: the SupervisorJob is the parent of launch, not of its children
scope.launch(SupervisorJob()) {
launch { loadProfile() } // throws
launch { loadFeed() } // cancelled anyway
}Passing SupervisorJob() to launch makes it the parent of that one coroutine. The children inside get a regular Job created by launch, so a failure still cancels the sibling. It also detaches the coroutine from the outer scope, which breaks structured concurrency.
// Fix: supervisorScope makes the children independent
scope.launch {
supervisorScope {
launch { loadProfile() }
launch { loadFeed() } // keeps running if loadProfile fails
}
}The follow-up: "Does supervisorScope catch the exception?" No. It stops propagation to siblings. The failing child's exception still goes to a CoroutineExceptionHandler or, without one, to the thread's uncaught exception handler, which on Android crashes the app.
5. Where does CoroutineExceptionHandler actually work?
What it tests: the difference between an exception that is uncaught and one that is waiting to be awaited.
A CoroutineExceptionHandler is a last resort. It is only called for uncaught exceptions, and only when installed on a root coroutine or a direct child of a supervisor. Installed on an inner launch of a regular job, it is ignored, because the child hands the failure to its parent instead.
async does not report to the handler at all when it is a root. It stores the exception in the Deferred and rethrows it from await(). So the handler never fires, and a try around await() is where you handle it.
The follow-up: "Then how do you handle errors in a ViewModel?" The strong answer catches expected failures close to the call, maps them to UI state, and rethrows CancellationException if a broad catch could see it. The handler is for logging what nobody expected.
6. What does withContext do, and who should call it?
What it tests: main safety as a contract, not a habit.
withContext suspends the caller, runs the block on another dispatcher, and resumes with the result. It does not start a new coroutine that outlives the call. Dispatchers.Main is for UI, Dispatchers.Default for CPU work sized to the cores, Dispatchers.IO for blocking IO on a larger pool that shares threads with Default.
The follow-up: "Should the ViewModel write withContext(Dispatchers.IO) before calling the repository?" No. The class that does the blocking work owns the switch, so every suspend function is safe to call from the main thread. The strong candidate also injects the dispatcher so a test can replace it. Room and Retrofit suspend functions are already main safe, so wrapping them again only adds noise.
7. Flow, StateFlow or SharedFlow?
What it tests: whether you separate state from events.
| Type | Hot or cold | Holds a value | Use it for |
|---|---|---|---|
| Flow | Cold | No | A stream that runs per collector: a query, a paged load |
| StateFlow | Hot | Always one, conflated, equal values skipped | Screen state the UI renders |
| SharedFlow | Hot | Configurable replay and buffer | Broadcasts to several collectors |
The follow-up: "Where do you put a one-off event like 'show a snackbar'?" A SharedFlow with no replay drops an event when nobody is collecting, for example during rotation. A StateFlow replays it on every new collector, so it shows twice. The senior answer names both failures, then models the event as part of state that the UI clears after handling it, or uses a Channel received as a flow when exactly one consumer must see each event once.
8. Cold or hot, and what does stateIn buy you?
What it tests: who starts the producer, and when it stops.
A cold flow runs its block again for every collector. Two collectors on one Room query means two queries. stateIn turns it hot and shares one upstream.
val uiState: StateFlow<UiState> = repository.observeOrders()
.map { UiState.Loaded(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Loading,
)The follow-up: "Why 5,000 milliseconds?" WhileSubscribed(5_000) keeps the upstream alive for five seconds after the last collector leaves. A rotation takes less than that, so the query does not restart. When the app goes to the background for longer, the upstream stops and stops costing work. Eagerly never stops, and Lazily never stops once started.
9. What does flowOn change, and where does the collector run?
What it tests: that a flow's context flows upstream only.
// Bug: everything runs on IO, including render()
lifecycleScope.launch {
withContext(Dispatchers.IO) {
readLines().map { parse(it) }.collect { render(it) } // render off Main
}
}
// Fix: flowOn moves only what is above it
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
readLines()
.map { parse(it) }
.flowOn(Dispatchers.Default) // readLines and parse run here
.collect { render(it) } // on Main, in the collector's context
}
}flowOn changes the context of every operator above it and leaves the collector where it is. You cannot use withContext inside a flow { } builder to emit from another dispatcher. That breaks context preservation and throws IllegalStateException.
The follow-up: "What does flowOn do to the buffer?" It introduces a channel between the two contexts, so the producer can run ahead of the collector. The candidate who knows that also knows why conflate() or buffer() placed next to it change behaviour.
10. How do you test coroutine code?
What it tests: virtual time and dispatcher control.
runTest runs the test body on a TestScope with a StandardTestDispatcher. delay skips virtual time instead of waiting, which is why a five second delay finishes at once. New coroutines are queued, not run, until the test yields or calls advanceUntilIdle(). UnconfinedTestDispatcher runs them eagerly, which is simpler for collectors and hides ordering bugs.
@Test
fun refresh_showsLoadedState() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(dispatcher)
try {
val vm = OrdersViewModel(FakeRepository(), ioDispatcher = dispatcher)
vm.refresh()
advanceUntilIdle()
assertEquals(UiState.Loaded(sampleOrders), vm.uiState.value)
} finally {
Dispatchers.resetMain()
}
}The follow-up: "Your stateIn with WhileSubscribed never updates in the test. Why?" Nothing collects it, so the upstream never starts. Collect it in backgroundScope for the length of the test, or read it with a library such as Turbine. The candidate who has hit this in real code answers in one sentence.
Recovery lines when you wobble
Every senior candidate gets one of these wrong at some point. What I score is the recovery.
- "Let me draw the job tree before I answer." Then say who the parent is. Half of these questions answer themselves once the tree is on the board.
- "I said the handler catches it. It doesn't for
async, the exception waits inawait(). Let me correct that." - "I'm not sure of the exact default for that buffer. What I do know is the behaviour I'd test for, and here is the test."
- "I've used
lifecycleScopethere before. Given what you've said about the save, I'd move it to a scope that outlives the screen."
A correction in your own words beats a confident wrong answer every time. It shows the interviewer how you would behave in a code review.
A 20-minute coroutines drill
- Write a scope with two
launchchildren and throw in one. Predict, run, explain. - Swap the parent for
supervisorScope. Predict, run, explain. - Write a CPU loop, cancel it, and watch it keep running. Add
ensureActive(). - Expose a
StateFlowwithstateInand test it withrunTestandbackgroundScope. - Say each answer out loud in under 45 seconds, then state the follow-up and answer that too.
Reading these answers helps. Watching your own prediction fail on a test run is what makes them stay.
Questions engineers ask about Kotlin coroutines interviews
What Kotlin coroutines questions come up in Android interviews?
Structured concurrency, scopes, cancellation, SupervisorJob, exception handling, dispatchers, Flow versus StateFlow and SharedFlow, lifecycle-aware collection and testing with runTest. The first question checks the name. The follow-up checks whether you know who owns the work.
How should I prepare for a Kotlin coroutines interview?
Write small programs that fail. Launch two children and throw in one. Cancel a loop that never suspends. Collect a StateFlow in the background. Then explain out loud what happened and why. Reading the documentation gives you names. Breaking code gives you answers.
Is Flow or LiveData expected in a senior Android interview?
Flow. LiveData still exists in older codebases and you should know how it relates, but current AndroidX guidance exposes StateFlow from the ViewModel and collects it with repeatOnLifecycle or collectAsStateWithLifecycle.