Android · 17 min read

Kotlin coroutines interview questions, and the follow-ups

Kotlin coroutines interview questions for 2026: structured concurrency, cancellation, exceptions, dispatchers, Flow and testing, with weak and senior answers.

Coroutines are the round where most Android loops I have sat on were decided. Not because the questions are hard to define, but because every question has a production bug behind it, and the interviewer is listening for whether you have met that bug. A candidate who says “use viewModelScope” has read the docs. A candidate who says “the scope cancelled it, the code did not cooperate” has debugged it.

This guide covers fourteen Kotlin coroutines interview questions, grouped by topic: structured concurrency, cancellation, exceptions, dispatchers, Flow and testing. For each: what is scored, a weak answer, a senior answer, and the follow-up. Code appears where it makes the mechanism clearer. None of the questions is attributed to a company.

What the interviewer is listening for

When I write up a coroutines round, three questions carry the whole score. Everything below is a probe for one of them.

Question Strong signal Weak signal
Who owns this work? Names the scope and what cancels it “It runs in the background”
What happens on failure? Traces the exception to where it lands “Wrap it in try/catch”
How do you know it works? Deterministic test in virtual time A delay in the test

If you remember nothing else, remember that “ownership” is the word interviewers are waiting for. Coroutines are a way to tie the lifetime of work to the lifetime of something that owns it, and most bugs are work that outlived its owner.

Structured concurrency

1. “What is structured concurrency, in one sentence and then in code?”

What is scored: whether you can connect the term to a concrete guarantee.

Weak answer: “It means coroutines are organised in a structured way.”

Senior answer: “Every coroutine has a parent scope, the parent does not complete until its children do, cancelling the parent cancels the children, and a child’s failure reaches the parent. So the lifetime of concurrent work is visible in the code’s shape, and nothing leaks by accident.”

suspend fun fetchAccount(client: AccountClient): Account = coroutineScope {
    val details = async { client.details() }
    val invoices = async { client.invoices() }
    Account(details.await(), invoices.await())
}

The follow-up: “What if one of the calls can fail and you still want to show the other?” Then coroutineScope is wrong, because it cancels everything on the first failure. Use supervisorScope and capture the failure per branch, and say that the choice is a product question: is a partial screen acceptable?

2. “A teammate launches two network calls with two launch blocks and writes results into shared variables. What is wrong?”

What is scored: seeing the race and the lost error path.

Weak answer: “It works, but async is cleaner.”

Senior answer: “Three things. There is no point where both results are known to be ready, so the combining code either polls or races. The shared variables are written from two coroutines, which on a multi-threaded dispatcher is a data race. And if one call fails, the other keeps running and the error lands in the scope’s handler instead of the caller. async inside coroutineScope fixes all three: results come back as values, completion is structural, and failure cancels the sibling and propagates to one place.”

The follow-up: “Where would this code live?” In the repository or a use case, as a suspend function, so the ViewModel only calls it and the concurrency is testable on its own.

3. “What is the difference between coroutineScope and supervisorScope?”

What is scored: failure semantics as a design choice.

Weak answer: “Supervisor is for when you want errors to be ignored.”

Senior answer: “In coroutineScope, a failing child cancels the scope and its siblings, then the exception is rethrown. In supervisorScope, a child’s failure does not cancel its siblings or the scope, so each child must handle its own failure. I use coroutineScope when the results only make sense together, like a cart and its payment methods, and supervisorScope for independent pieces, like widgets on a dashboard.”

suspend fun <T> Deferred<T>.awaitOrNull(): T? =
    try {
        await()
    } catch (e: CancellationException) {
        throw e
    } catch (e: Exception) {
        null
    }

suspend fun fetchHome(client: HomeClient): Home = supervisorScope {
    val headlines = async { client.headlines() }
    val forecast = async { client.forecast() }
    Home(headlines.awaitOrNull(), forecast.awaitOrNull())
}

The follow-up: “Is viewModelScope a supervisor?” Yes, it is built on a SupervisorJob, so one failed launch does not cancel the others. But a supervisor limits cancellation, it does not handle anything: with no exception handler installed, an uncaught exception in one of those launches still reaches the thread’s uncaught handler and crashes the app.

Cancellation

4. “The user leaves the screen, but the coroutine keeps using CPU for seconds. Why?”

What is scored: knowing cancellation is cooperative, and the three escape routes.

Weak answer: “You should use viewModelScope so it gets cancelled.”

Senior answer: “viewModelScope did request cancellation. Cancellation is only observed at suspension points and checks. So I look for three things in order: blocking code or a CPU loop that never suspends, a broad catch that swallows the CancellationException, and work that escaped to another scope, such as an application scope or NonCancellable. For CPU loops I add ensureActive() at sensible intervals. For blocking libraries I cannot change, runInterruptible turns cancellation into a thread interrupt.”

val summaries = withContext(Dispatchers.Default) {
    entries.map { entry ->
        ensureActive()
        summarize(entry)
    }
}

The follow-up: “The code is already inside withContext(Dispatchers.IO). So why is it still running?” Because moving a blocking call off the main thread makes it main-safe, not interruptible. The job may be cancelled while its thread sits blocked. Those are two different guarantees.

5. “Why must you rethrow CancellationException?”

What is scored: understanding cancellation as an exception.

Weak answer: “Because the docs say so.”

Senior answer: “Cancellation is delivered as a CancellationException. If a catch (e: Exception) catches it and continues, the coroutine keeps running after its owner is gone, and a loop can keep retrying forever. So either catch specific exceptions, or catch CancellationException first and rethrow it. runCatching has the same problem, because it catches everything, so I avoid it around suspending calls unless I rethrow cancellation.”

try {
    repo.sync()
} catch (e: CancellationException) {
    throw e
} catch (e: IOException) {
    showRetry()
}

The follow-up: “When is NonCancellable legitimate?” For short cleanup that must finish after cancellation, like closing a resource or writing a final state in finally. Wrapping a whole operation in it is escaping ownership.

6. “How do timeouts work with coroutines?”

What is scored: knowing the two variants and their failure shapes.

Weak answer: “You use withTimeout.”

Senior answer: “withTimeout cancels the block and throws TimeoutCancellationException, which is a CancellationException, so a careless catch can swallow it. withTimeoutOrNull returns null instead, which is often easier to handle. Both depend on cooperative cancellation, so a blocking call inside will overrun the timeout. And for network calls, a client timeout does not mean the server did nothing, so the caller still needs an idempotent retry path.”

Exceptions

7. “Where does an exception in a launch go, and where does one in async go?”

What is scored: tracing propagation correctly.

Weak answer: “You catch it with try/catch around the launch.”

Senior answer: “A try/catch around launch catches nothing, because launch returns immediately. An exception inside launch propagates to the parent job. In a regular scope that cancels the parent and siblings; at a root, it goes to the CoroutineExceptionHandler or the thread’s uncaught handler, which crashes the app. With async, the exception is stored in the Deferred and thrown at await, but the child still fails its parent in a regular scope, so wrapping await alone does not prevent sibling cancellation.”

The follow-up: “So where do you put the try/catch?” Inside the coroutine, around the call that can fail, or in the suspend function that owns the operation, so the error becomes a value the UI can render.

8. “What is CoroutineExceptionHandler good for?”

What is scored: not using it as error handling.

Weak answer: “You install it to handle all errors.”

Senior answer: “It is a last-resort hook for uncaught exceptions in root coroutines started with launch. It does not work on async, and installing it on a child has no effect. I use it for logging and crash reporting at the top level. Real error handling belongs next to the operation, where it can turn a failure into a state the user sees.”

Dispatchers and threads

9. “What is the difference between Dispatchers.Default, IO and Main?”

What is scored: matching work to the pool, and main-safety.

Weak answer: “IO for network, Default for everything else.”

Senior answer: “Main is the UI thread. Default is sized to the CPU cores, for CPU work like parsing or sorting large lists. IO allows many more threads, because blocking I/O mostly waits. The convention that matters is main-safety: a suspend function should be safe to call from the main thread, so the function that blocks is responsible for switching with withContext, not every caller.”

The follow-up: “Why inject dispatchers?” So tests can replace them with a test dispatcher. A hardcoded Dispatchers.IO inside a repository runs on real threads in tests, escapes virtual time and causes flaky failures.

10. “An ANR trace points at a coroutine. How do you investigate?”

What is scored: systematic diagnosis, not a reflex fix.

Weak answer: “Move it to Dispatchers.IO.”

Senior answer: “First I read what the main thread is doing in the trace. If it is disk or network behind a suspend signature that never switched context, the function is not main-safe and I fix it there. If it is runBlocking on the main thread, that goes. If the main thread waits on a lock or a channel held by background work, changing dispatchers keeps the deadlock, so I follow the holder. Two less obvious causes: the IO pool saturated by blocking calls, so work the main thread depends on queues behind them, and plain main-thread overload from state emitted far more often than the UI can draw. The evidence comes from the ANR trace in Android vitals or ApplicationExitInfo, thread states in Perfetto, and a coroutine dump from the debug agent. Then I add StrictMode in debug and a regression test so the class of bug is caught earlier.”

Flow

11. “StateFlow or SharedFlow, and for what?”

What is scored: delivery guarantees, not parameters.

Weak answer: “StateFlow has an initial value, SharedFlow does not.”

Senior answer: “StateFlow always has a value, conflates so slow collectors see only the latest, and ignores equal values. That is exactly what screen state needs. SharedFlow sends every emission to every active collector, with optional replay and buffering, which suits broadcasts like a forced logout. For one-time UI events neither is right by default: StateFlow replays after rotation and SharedFlow without replay drops events when nobody collects. I model those as state that the UI consumes and clears.”

The follow-up: “How do you stop StateFlow emitting duplicates?” It already skips equal values. If you see duplicates, your values are not equal, usually a mutable object or an array field. The fix is immutable data classes, not distinctUntilChanged.

12. “Where does catch go in a Flow chain, and why does placement matter?”

What is scored: knowing operators apply upstream.

Weak answer: “At the end, to catch everything.”

Senior answer: “catch only handles exceptions from operators above it. Put it before the operator that fails and it sees nothing. It also does not catch exceptions thrown in the collector. So a typical ViewModel chain maps to UI state, then catch to emit an error state, then stateIn.”

val uiState: StateFlow<FeedState> = repository.posts()
    .map<List<Post>, FeedState> { FeedState.Ready(it) }
    .catch { cause -> emit(FeedState.Failed(cause)) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
        initialValue = FeedState.Loading,
    )

The follow-up: “Why five seconds?” So a configuration change, where the old UI unsubscribes and the new one subscribes a moment later, does not restart the upstream. Pair it with collectAsStateWithLifecycle so nothing runs in the background.

13. “When would you use flatMapLatest in a search screen?”

What is scored: cancellation of stale work and ordering.

Weak answer: “To make search faster.”

Senior answer: “When each query should cancel the previous request. debounce waits for typing to pause, distinctUntilChanged skips repeated queries, and flatMapLatest cancels the old search when a new query arrives, so an old response can never overwrite a newer one. If the request is blocking and ignores cancellation, the stale result can still arrive, so I also make sure results are tied to the query that produced them.”

Testing

14. “How do you test a ViewModel that exposes a StateFlow?”

What is scored: deterministic tests, not sleep-and-hope.

Weak answer: “Call the function, add a delay, then check the value.”

Senior answer: “runTest so delays are virtual, a main dispatcher rule that swaps Dispatchers.Main for a test dispatcher, and Turbine to assert emissions in order. If I need to see the loading state, the fake repository waits on a CompletableDeferred that the test completes, so the test decides when loading ends instead of the scheduler. With WhileSubscribed, I start collecting before triggering the action, or the early emissions are missed.”

@Test fun showsLoadingBeforePosts() = runTest {
    val release = CompletableDeferred<List<Post>>()
    val viewModel = FeedViewModel(FakePostsRepository { release.await() })
    viewModel.uiState.test {
        assertEquals(FeedState.Loading, awaitItem())
        release.complete(listOf(samplePost))
        assertEquals(FeedState.Ready(listOf(samplePost)), awaitItem())
    }
}

The follow-up: “Why not UnconfinedTestDispatcher everywhere?” It runs coroutines eagerly, which is convenient but hides ordering bugs. I use StandardTestDispatcher by default and reach for unconfined only when I want eager collection.

Mistakes that cost the round

The same few patterns appear in my notes again and again:

  • Treating viewModelScope as a guarantee instead of a request.
  • Saying Dispatchers.IO makes blocking code cancellable.
  • A try/catch around launch.
  • runCatching around suspend calls with no cancellation rethrow.
  • “Use SharedFlow for events” with no word about what happens when nobody is collecting.
  • Tests with real delays.

None of these are knowledge gaps in the usual sense. They are overclaims. The fix is to attach every API to its contract: what it promises, and what it does not. When I hear a candidate volunteer the limit of their own answer, I write it down as a senior signal every time.

What changed in 2024 to 2026

  • Kotlin 2.0.0, released 21 May 2024, made the K2 compiler the default and Stable on JVM, Native, Wasm and JS (What’s new in Kotlin 2.0.0).
  • In the same release the Jetpack Compose compiler plugin moved into the Kotlin repository and gained its own Gradle plugin.
  • Answers about build speed or compiler plugins that describe the old compiler describe a toolchain most projects have left.

Sources

What to do next

Pick the three questions above where your first answer named an API but not its limit. Say them out loud with the limit included. That one habit changes how a coroutines round reads in the interviewer’s notes more than any new API you could learn this week.

Free guide: the Phoenix Method