Coroutines Under the Hood Explained
KOTLIN › Coroutines
suspend is not a runtime feature. There is no such thing as a suspending function in JVM bytecode, no special instruction, no VM support. It is a **compiler transformation**, and the whole of coroutines falls out of understanding it.
The transformation has two halves. First, every suspend fun gains a hidden final parameter:
suspend fun loadUser(id: String): User
compiles, roughly, to:
Object loadUser(String id, Continuation<User> completion)
Two things changed. A Continuation was added, which is the callback the function will use to deliver its result later. And the return type became Object, because the function now has two possible outcomes: the actual User, or a marker meaning "I suspended, I will call you back".
That single signature explains several rules you already know. A suspend function can only be called from another suspend function, because only a suspending caller has a Continuation to pass. And Java code cannot call one comfortably, because from Java it is an ugly two-argument method returning Object.
This style has a name, **continuation-passing style**, and the shorthand CPS is worth using in an interview. Callbacks, written by the compiler instead of by you.
The Continuation itself is almost disappointingly small:
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
A context, and one method to be called with the outcome. Result<T> carries either a value or an exception, which is why a resumed coroutine can either continue normally or have an exception thrown at the suspension point.
You will also see the two extensions built on it:
continuation.resume(value) // resumeWith(Result.success(value))
continuation.resumeWithException(error) // resumeWith(Result.failure(error))
Think of a Continuation as **the rest of the function, reified as an object**. When loadUser suspends at a network call, what needs to happen later is "take this User and run the remaining lines". The Continuation is exactly that, packaged up so it can be stored, passed to a callback, and invoked from another thread.
That reframing answers a common interview question in one sentence. Where is a suspended coroutine stored? **On the heap, as a Continuation object**, referenced by whatever it is waiting on. Not on a thread stack, which is why the thread was free to go and do something else.
A function can have several suspension points, so a single Continuation has to know *which* one to resume at. The compiler solves that by turning the function body into a **state machine**.
suspend fun loadProfile(id: String): Profile {
val user = api.user(id) // suspension point 1
val posts = api.posts(id) // suspension point 2
return Profile(user, posts)
}
Conceptually the compiler produces one class with a label field and a when over it:
// Sketch of the generated shape, not real source
class LoadProfileContinuation : ContinuationImpl {
var label = 0
var user: User? = null // local promoted to a field
var id: String? = null
}
fun loadProfile(id: String, cont: Continuation<Profile>): Any? {
val sm = cont as LoadProfileContinuation
when (sm.label) {
0 -> { sm.label = 1; sm.id = id
val r = api.user(id, sm); if (r == COROUTINE_SUSPENDED) return r
/* fall through if it returned immediately */ }
1 -> { sm.user = sm.result as User; sm.label = 2
val r = api.posts(sm.id!!, sm); if (r == COROUTINE_SUSPENDED) return r }
2 -> return Profile(sm.user!!, sm.result as List<Post>)
}
}
The detail worth remembering: **local variables that survive a suspension point become fields on the continuation object**. They cannot live on the stack, because the stack frame is gone the moment the function returns. That is also why capturing a large object in a long-lived coroutine keeps it alive: it is a field on a heap object, not a stack slot.
The state machine returns one of two things, and the second one is the trick that makes coroutines fast.
val r = api.user(id, sm)
if (r == COROUTINE_SUSPENDED) return r // really suspended, unwind
// otherwise r is the actual User, keep going on this thread
COROUTINE_SUSPENDED is a sentinel object. Returning it means "I have not finished; I kept your continuation and will call resumeWith later". Returning anything else means the value is already available.
That second path is the **fast path**, and it matters more than people expect. A suspend fun that finds its answer in a cache never actually suspends:
suspend fun user(id: String): User =
cache[id] ?: api.fetch(id) // cache hit: returns directly, no suspension
On a cache hit there is no continuation stored, no dispatch, no thread handoff, just a normal method call with a slightly awkward signature. The cost is close to a regular function call.
This is the answer to "are suspend functions slow?". Marking something suspend costs an allocation only when it genuinely suspends. It is also why Flow can be efficient despite looking like it should allocate constantly, and why Dispatchers.Main.immediate is worth having: both are about avoiding work when a value is already available.
Now the cost comparison interviewers like, with the reason underneath it rather than just the numbers.
A JVM thread reserves a stack, typically around 512KB to 1MB of virtual address space, and is scheduled by the operating system, so switching between two threads means a kernel-level context switch. A hundred thousand threads is not a configuration problem, it is physically unreasonable.
A coroutine is a Continuation object: a few dozen bytes of fields on the heap, switched by an ordinary method call. A hundred thousand of them is a modest allocation.
// Fine: ~100k heap objects
runBlocking {
repeat(100_000) { launch { delay(1000); print(".") } }
}
// Not fine: ~100k OS threads, each reserving stack space
repeat(100_000) { thread { Thread.sleep(1000); print(".") } }
Two honest qualifications, because overstating this is a tell.
First, the saving is in **waiting**, not computing. A hundred thousand coroutines doing CPU work still contend for the same cores; they are cheap to have, not free to run. Second, coroutines still need threads underneath. Dispatchers.Default has real threads, and blocking one of them with Thread.sleep inside a coroutine is exactly as harmful as blocking it anywhere else. Cheap suspension only helps if you actually suspend.
The other half of the machinery is CoroutineContext, and the thing to realise is that it is a **typed, immutable set keyed by element type**, closer to a map than to a list.
public interface CoroutineContext {
public operator fun <E : Element> get(key: Key<E>): E?
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
public operator fun plus(context: CoroutineContext): CoroutineContext
public fun minusKey(key: Key<*>): CoroutineContext
public interface Element : CoroutineContext {
public val key: Key<*>
}
}
Note that Element extends CoroutineContext. A single element *is* a context of one, which is why Dispatchers.IO can be passed where a context is expected, and why + composes them so naturally.
val ctx = Dispatchers.IO + CoroutineName("sync") + SupervisorJob()
ctx[CoroutineName] // CoroutineName(sync)
ctx[ContinuationInterceptor] // Dispatchers.IO
ctx.minusKey(CoroutineName) // same context without the name
Each element type has a companion object acting as its Key, which is what makes ctx[CoroutineName] return a correctly typed CoroutineName? with no cast.
The rule for +: **right side wins per key**, non-overlapping keys are kept. Since a dispatcher is stored under the ContinuationInterceptor key rather than its own, adding a dispatcher replaces the dispatcher and leaves the name and job untouched.
Job is a context element like any other, and structured concurrency is a consequence of that rather than a separate feature.
When you call launch, the builder does roughly this: take the scope's context, create a new Job whose **parent** is the Job found in that context, and run the child with the scope's context plus the new Job.
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
...
): Job {
val newContext = newCoroutineContext(context) // scope context + overrides
val coroutine = StandaloneCoroutine(newContext, active = true)
// the new Job's parent is newContext[Job]
...
}
Everything structured falls out of that single link:
- The parent knows its children, so it can wait for them before completing. - Cancelling a parent cancels every child, transitively. - A child failure is reported to the parent, which decides whether to cancel its siblings. - SupervisorJob is simply a Job that overrides the last of those to say "no".
It also explains a bug people hit and cannot diagnose. Pass a *fresh* Job() into a builder and you have severed the link:
viewModelScope.launch(Job()) { forever() } // NOT cancelled when the scope is
The new Job replaces the scope's job in the child's context, so the child has no parent, and onCleared cancels a hierarchy this coroutine is no longer part of. It leaks in exactly the way viewModelScope exists to prevent.
One context element gets special treatment from the machinery: ContinuationInterceptor. It is the hook a dispatcher uses, and it is where "which thread" is actually decided.
public interface ContinuationInterceptor : CoroutineContext.Element {
public fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>
}
Before a coroutine runs, the runtime asks the context's interceptor to **wrap** its continuation. CoroutineDispatcher implements this by returning a DispatchedContinuation, whose resumeWith does not run the code directly but instead packages it as a Runnable and hands it to dispatch().
So the chain is: something completes, it calls continuation.resumeWith(result), that continuation is the dispatcher's wrapper, and the wrapper schedules the real resumption onto a thread from its pool.
// Roughly what the wrapper does
override fun resumeWith(result: Result<T>) {
dispatcher.dispatch(context, Runnable { realContinuation.resumeWith(result) })
}
Two things follow. A dispatcher acts **only at resumption**, never continuously, which is why a suspended coroutine occupies no thread. And Dispatchers.Unconfined is not a strange special case: it is simply an interceptor that declines to redirect, resuming inline on whatever thread called resumeWith.
The interceptor is also why a dispatcher is stored under the ContinuationInterceptor key rather than a key of its own, which is the mechanical reason a context can only hold one dispatcher.
Builders take a start parameter that decides *when and where* the body first runs. Most people only ever use the default, and the other three are all fair interview material.
scope.launch(start = CoroutineStart.LAZY) { work() }
- **DEFAULT**: schedule the body through the dispatcher immediately. The coroutine starts soon, but not necessarily before launch returns. - **LAZY**: create the coroutine but run nothing until someone calls start(), join(), or await(). Useful for building a graph of work up front and triggering it later. - **ATOMIC**: schedule immediately and guarantee the body begins even if the coroutine is cancelled before it starts. For try/finally acquisition patterns where the cleanup must be reachable. - **UNDISPATCHED**: run the body immediately on the current thread up to the first suspension point, then dispatch normally.
val deferred = scope.async(start = CoroutineStart.LAZY) { expensive() }
// nothing has run yet
val result = deferred.await() // starts it here
The one that bites: a LAZY coroutine that nobody starts still holds its place in the job hierarchy, so a parent coroutineScope will wait for it forever. Create one and forget to start() it and you have a hang, not a no-op.
UNDISPATCHED is worth distinguishing from Unconfined: undispatched is about **where the body begins**, unconfined is about **where it resumes** after suspending.
Sometimes you need to turn a callback-based API into a suspend function. The bridge is suspendCancellableCoroutine:
suspend fun fetchLocation(): Location =
suspendCancellableCoroutine { cont ->
val callback = object : LocationCallback() {
override fun onLocation(loc: Location) {
cont.resume(loc)
}
override fun onError(e: Exception) {
cont.resumeWithException(e)
}
}
locationClient.requestLocation(callback)
cont.invokeOnCancellation {
locationClient.removeCallback(callback)
}
}
The lambda receives a CancellableContinuation. You have three responsibilities:
1. **Resume on success** with cont.resume(value) -- exactly once 2. **Resume on failure** with cont.resumeWithException(e) -- if the callback reports an error 3. **Clean up on cancellation** with cont.invokeOnCancellation { } -- unregister the callback so the underlying API doesn't leak resources
The "cancellable" part matters: if the coroutine's Job is cancelled while waiting, invokeOnCancellation fires so you can tear down the callback. Without it, a cancelled coroutine would leave a dangling listener.
Use suspendCancellableCoroutine (not the non-cancellable suspendCoroutine) in almost every case. The non-cancellable variant ignores Job cancellation entirely, which defeats structured concurrency.
There are two bridging functions and the difference is a genuine bug source.
suspendCoroutine { cont -> ... } // cannot be cancelled
suspendCancellableCoroutine { cont -> ... } // can be
With suspendCoroutine, once you suspend, cancelling the coroutine has no effect until the callback fires. The coroutine is stuck, the Job reports itself cancelling but never completes, and a parent waiting on it hangs. If the callback never fires at all, that continuation is leaked for the life of the process. Prefer suspendCancellableCoroutine essentially always.
Its extra capability is invokeOnCancellation, the hook for releasing the underlying resource:
suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
enqueue(object : Callback {
override fun onResponse(call: Call, res: Response) = cont.resume(res)
override fun onFailure(call: Call, e: IOException) = cont.resumeWithException(e)
})
cont.invokeOnCancellation { cancel() } // abort the HTTP call itself
}
Without that line, cancelling the screen stops your coroutine but leaves the request running to completion, still burning battery and radio.
Two rules for writing these. **Resume exactly once**: a second resume throws IllegalStateException, and callback APIs that can fire twice need guarding. And prefer cont.resume(value) inside the callback rather than doing work there, since that code runs on whatever thread the library chose.
Finish with what suspend does **not** mean, because these three misconceptions come up constantly.
**It does not mean background.** A suspend function runs on whatever dispatcher its caller is using. Call one from Dispatchers.Main and it runs on the main thread. Nothing about the keyword moves work anywhere; only withContext and the builders do that.
suspend fun compute(): Int {
return heavyLoop() // runs on the CALLER's thread; blocks Main if called there
}
**It does not mean non-blocking.** suspend is a promise the compiler cannot enforce. Put Thread.sleep or a synchronous file read inside one and you have blocked a real thread, exactly as if it were a normal function. Suspension is cooperative: the code has to actually call something that suspends.
**It does not mean parallel.** Two suspending calls in sequence run in sequence:
val a = api.first() // waits
val b = api.second() // then waits again: total = sum of both
coroutineScope { // this is how you get overlap
val a = async { api.first() }
val b = async { api.second() }
a.await() to b.await()
}
The keyword only makes waiting cheap. Concurrency comes from builders, and parallelism comes from dispatchers with more than one thread.
The whole topic in one arc.
suspend is a compiler transformation into continuation-passing style: a hidden Continuation parameter, a return type wide enough to carry a COROUTINE_SUSPENDED sentinel, and a body rewritten as a state machine with a label and its surviving locals promoted to fields. A suspended coroutine is that object on the heap, which is why the thread is free and why having a hundred thousand of them is reasonable.
Around it sits CoroutineContext, an immutable set keyed by element type. Job is the element that creates the parent-child links structured concurrency is built from, and ContinuationInterceptor is the element a dispatcher implements to seize control at resumption time.
The answers worth having ready:
- **What does suspend compile to?** A function taking a Continuation, returning either the value or a suspension marker, with the body as a state machine. - **Why are coroutines cheap?** Paused state is a small heap object rather than a reserved thread stack plus an OS-schedulable entity. - **How does a dispatcher decide the thread?** By intercepting the continuation so resumeWith dispatches instead of running inline. - **How does cancellation reach a child?** Through the Job link established when the builder read the parent job out of the context.
If an interviewer asks you to summarise coroutines in one sentence, this is the one that lands: **coroutines are callbacks written by the compiler, with a hierarchy of jobs bolted on so that nobody forgets to cancel them.**