Kotlin concurrency
Kotlin coroutines and Flow interview guide
Explain coroutine ownership and stream semantics in terms of the user-visible system.
The Senior Signal (book, from $59)
Kotlin concurrency: what matters most
For Kotlin coroutine interviews, explain who owns each coroutine, which scope defines its lifetime, how cancellation propagates and which dispatcher matches the work. Structured concurrency keeps child work attached to a meaningful parent. Flow is cold by default, while StateFlow and SharedFlow model different hot-stream behaviours. A senior answer distinguishes transient events from durable state, explains replay and buffering, and avoids collecting the same expensive upstream work repeatedly. Connect the concepts to lifecycle-aware collection, stale requests, retry policy, offline queues and test dispatchers. The goal is to prove that concurrent work ends when it should and the UI cannot display an impossible state.
Trace ownership first
For every launch, identify the parent scope and the product event that should cancel it. Global work requires an explicit application-level owner, not GlobalScope.
A durable upload is not a screen-owned coroutine. If the product promises the upload survives the user leaving, the owner is a persisted operation with its own scheduler, and the screen only observes it.
- Parent and child lifetime
- Cancellation propagation
- Exception supervision
- Dispatcher choice
- Shared upstream work
- Deterministic tests
StateFlow, SharedFlow and channels
StateFlow represents a current value. SharedFlow broadcasts values with configurable replay and buffering. Channels coordinate producers and consumers. Choose based on semantics, not fashion.
A navigation event is not automatically a stream. Ask whether the event must survive a collector disappearing and how duplicate handling works.
A cancellation boundary, tested
A common failure is a cancelled login that shows an error. A catch block turns the cancellation into a failure value, and the screen renders it. Here is a Result boundary that does not do that.
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
suspend fun <T> operationResult(block: suspend () -> T): Result<T> =
try {
val value = block()
currentCoroutineContext().ensureActive()
Result.success(value)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Exception) {
currentCoroutineContext().ensureActive()
Result.failure(failure)
}The first catch rethrows cancellation, so it never becomes a failure value. The last catch takes Exception, not every Throwable. The two ensureActive calls cover a block that returns, or throws an ordinary exception, after its context was cancelled: the helper publishes neither. They do not make long-running work cooperative. That is still the job of the code inside the block.
A test like this should check the helper with a handful of contract cases. A useful negative control is the same operation wrapped in runCatching, which returns the cancellation as a failure. That is the behaviour to describe when an interviewer asks why a cancelled login shows an error. A suite like this can also check that a regular flow builder starts its upstream once per collector, that two equal StateFlow values reach an active collector once, and that a zero-replay SharedFlow does not hand an old value to a late subscriber.
Limits: checks like these run on the JVM with a test dispatcher, so Android lifecycle behaviour stays untested. Changing a catch block also does not isolate accounts across the app. The UI still has to know which attempt is current.
Interview failure cases
Practice duplicate collectors, a retry loop after cancellation, blocking work on Main, a swallowed exception and an old request overwriting new state.
For each, explain how a test would force the bad ordering instead of hoping the race happens.