Coroutines Under the Hood Interview Questions
KOTLIN › Coroutines
Explain what happens, mechanically, between a coroutine calling a suspending network function and that coroutine resuming with the response.
What a strong answer covers: The call site passes its Continuation into the network function as the hidden last parameter. That function registers a callback with the HTTP client, stores the continuation, and returns the COROUTINE_SUSPENDED sentinel. The caller state machine sees the sentinel and returns too, so the whole call stack unwinds and the thread goes back to its pool with nothing of ours on it. The paused state is not lost because the state machine object is on the heap: it holds the label saying which suspension point we stopped at, plus any locals that had to survive, promoted from stack slots to fields. Later the HTTP client fires its callback on one of its own threads and calls resumeWith with the response. That continuation is not the raw one, though, because the dispatcher intercepted it: CoroutineDispatcher implements ContinuationInterceptor, so what the callback actually holds is a DispatchedContinuation whose resumeWith packages the resumption as a Runnable and hands it to dispatch. The dispatcher puts that Runnable on a thread from its pool, that thread re-enters the state machine method with the label now pointing at the next branch, and execution continues from the line after the network call. So the resumption is on a different thread from the one that suspended, with the state restored from heap fields rather than a stack.
How would you explain to a junior developer why marking a function suspend does not make it run in the background?
What a strong answer covers: I would separate two ideas that the keyword blurs for people: where code runs, and how it waits. Suspend only changes the second. It gives the function the ability to pause and give the thread back, but it says nothing about which thread it started on, and a suspend function always begins on whatever dispatcher its caller is using. So calling one from viewModelScope, which is Main, runs its body on the main thread. The demonstration that lands is a suspend function containing a tight CPU loop or a Thread.sleep. Call it from Main and the UI freezes, exactly as it would with a normal function, because nothing in the body ever suspends and the compiler cannot enforce the non-blocking promise. Then I would show the fix, which is that withContext is the thing that changes threads, and that it belongs at the innermost place where the blocking actually happens rather than sprinkled everywhere. The third confusion usually follows immediately: that two suspending calls in a row run at the same time. They do not, they run in sequence and the durations add. Getting overlap requires async inside a coroutineScope and awaiting both, which is a different concept, concurrency from builders rather than from the keyword.
A colleague reports that a coroutine holds on to a large bitmap long after they expected it to be freed. How does knowing the compiler transform help you diagnose that?
What a strong answer covers: Because it tells you exactly where that reference lives. When the compiler rewrites the function into a state machine, every local that is still needed after a suspension point cannot stay on the stack, since suspension is a genuine return and the frame is discarded. Those locals become fields on the continuation object, and that object is alive for as long as something holds the continuation, which is whatever the coroutine is currently waiting on. So a bitmap assigned to a local before a long delay or a slow network call is a strong reference on a heap object with the lifetime of the suspension, not a stack value that disappears at the next safepoint. The practical fixes follow from that. Narrow the scope so the value is not live across the suspension point, for example by extracting the part that uses the bitmap into its own function that completes before the wait, or by nulling the reference explicitly before suspending. Check whether the coroutine really needs to be long-lived at all, since a lazily started coroutine nobody starts, or one launched on a detached Job, can keep its captures alive indefinitely. And confirm with a heap dump: the continuation class name in the dominator tree usually names the enclosing function, which points straight at the offending local.
Why is a CoroutineContext a set keyed by element type rather than a simple list, and what practical consequences does that have?
What a strong answer covers: Because lookup has to be by type and has to be typed. Elements each carry a Key that is usually the companion object of the element class, so ctx[CoroutineName] returns a CoroutineName? with no cast, and the machinery can ask for exactly the elements it cares about, such as the Job or the interceptor, without scanning. Element itself extends CoroutineContext, so a single element is a context of one, which is why you can pass Dispatchers.IO where a context is expected and why plus composes them naturally. The consequences are all about plus replacing per key. A context can hold only one dispatcher, mechanically, because dispatchers are stored under the ContinuationInterceptor key rather than under their own type, so adding a second replaces the first. Adding a dispatcher leaves your CoroutineName and Job untouched, because those are different keys. And the classic bug falls out of the same rule: passing a fresh Job() into a builder replaces the scope job under the Job key before the builder reads it, so the new coroutine has no parent and escapes cancellation entirely. Understanding the merge semantics turns that from a mystery into an obvious consequence.
What is the fast path in a suspend function, and why does it matter for something like Flow?
What a strong answer covers: A suspend function does not necessarily suspend. The generated code calls the next suspending function and checks its return value: if it is the COROUTINE_SUSPENDED sentinel, the state machine saves its label and unwinds, but if it is anything else the value was already available and execution simply continues on the same thread with no continuation stored, no dispatch, and no allocation. So a suspend function that finds its answer in a cache costs about as much as a normal method call. It matters for Flow because a flow collection is a long chain of suspending calls, emit into map into filter into the collector, and if every one of those necessarily allocated and dispatched, operator chains would be far more expensive than they are. Most emissions travel the fast path straight through the chain on one thread. It is also the reason flowOn and buffer are explicit opt-ins rather than defaults: inserting a channel between two stages is what forces a real suspension and a dispatch, so the library makes you ask for it. The same idea shows up in Dispatchers.Main.immediate, which exists to avoid a pointless post when you are already on the right thread. The general principle is that the coroutines library works hard to make the common case not actually suspend.