You open the Kotlin decompiler on a function that just calls delay(1000) between two println statements, and you see something you did not write. A class named YourFunction$1 extends ContinuationImpl, with a field called I$0, an int label, and an invokeSuspend method wrapped around a switch on label. The outer function now takes a Continuation parameter it never had in the source, and its return type is Object instead of Unit. The code works. You understand the API. But the generated structure looks alien, and you have no idea why any of that needs to exist.
In this article, you'll trace a single suspend function through every layer of the Kotlin coroutine pipeline: the Continuation contract in the standard library, the Continuation Passing Style rewrite that adds the hidden parameter, the state machine with its label integer and spill fields, the trampoline loop inside BaseContinuationImpl, the DispatchedContinuation that bridges to dispatchers, and the exception flow carried by Result<T>. Every transformation here is taken from the real Kotlin standard library and kotlinx.coroutines source, not from an illustrative sketch.
TL;DR
suspend fun foo(): Intis compiled intofun foo(completion: Continuation<Int>): Any?. CPS adds a hidden parameter and a dual purpose return type.- Every suspend function body becomes a state machine: an anonymous
ContinuationImplsubclass with alabel: Intfield, spill fields for live locals, and aninvokeSuspendmethod containing aswitch(label). - Returning
COROUTINE_SUSPENDEDmeans the function paused and will deliver its result later through its continuation. Returning anything else is the synchronous fast path. BaseContinuationImpl.resumeWithuses a trampoline loop, so a chain of synchronous resumptions never overflows the JVM stack.
The Continuation contract
The entire coroutine system is built on top of one two member interface defined in kotlin.coroutines. If you open the standard library source, the file is short enough to read in one sitting:
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}That is the whole language level API for suspension. The context property carries the coroutine's execution environment: a ContinuationInterceptor (the dispatcher), a Job, an optional CoroutineExceptionHandler, a CoroutineName, and any custom element someone installed. The resumeWith method is how a paused coroutine is brought back to life. Call it with a Result.success(value) to deliver a value, or Result.failure(exception) to deliver an exception.
The delivery envelope itself, Result<T>, is a JVM inline value class that wraps either the successful value or a Failure holding a Throwable:
@JvmInline
public value class Result<out T> internal constructor(
internal val value: Any?
) : Serializable {
public val isSuccess: Boolean get() = value !is Failure
public val isFailure: Boolean get() = value is Failure
internal class Failure(@JvmField val exception: Throwable) : Serializable
}Because Result is a value class, there is zero boxing on the happy path. A Result.success(42) is just the boxed Int itself at runtime. Only the failure case actually allocates a Failure wrapper. This is one of the small reasons why normal coroutine resumption is as cheap as a plain method call.
Hold on to this mental model. A Continuation is a resumable callback that carries its own context. Every other piece of machinery in kotlin.coroutines and kotlinx.coroutines exists to produce, consume, schedule, intercept, or compose these callbacks.
Continuation Passing Style
The JVM has no instruction for "pause this method and return later." When a JVM frame returns, its locals, operand stack, and program counter are gone. For suspend to mean anything, the compiler needs to produce code that can voluntarily surrender its stack frame and later rebuild it. That is what Continuation Passing Style does.
CPS is a compiler technique from functional language research. Instead of letting a function return a value, you pass it a callback (the "continuation") and the function calls that callback with the result. The Kotlin compiler applies this transformation to every suspend function. The author's signature,
suspend fun fetchUser(id: Long): User { ... }becomes, at the bytecode level, a function shaped like this:
fun fetchUser(id: Long, $completion: Continuation<User>): Any?Two things changed. First, the function picked up an extra trailing parameter, $completion, which is the continuation that represents the caller's "rest of the work." Second, the return type widened from User to Any?. That widening is not sloppy typing. It is essential. The function now has two possible things it may hand back:
- The real
Userresult, when the function was able to complete synchronously. This is the fast path. - The singleton
COROUTINE_SUSPENDEDsentinel, when the function actually paused and will deliver its result later by calling$completion.resumeWith(...)from somewhere else.
The COROUTINE_SUSPENDED sentinel
COROUTINE_SUSPENDED is defined in kotlin.coroutines.intrinsics as a tiny enum singleton:
internal enum class CoroutineSingletons {
COROUTINE_SUSPENDED,
UNDECIDED,
RESUMED
}
public val COROUTINE_SUSPENDED: Any get() = CoroutineSingletons.COROUTINE_SUSPENDEDEvery suspend function's caller inspects the return value with reference equality. If it is === COROUTINE_SUSPENDED, the caller also returns COROUTINE_SUSPENDED. The entire suspend chain unwinds back to whoever kicked off the coroutine (a dispatcher worker, runBlocking, launch, etc.). If it is any other value, the function completed synchronously and the caller treats the return value as the real result and keeps going.
This dual purpose return value is what makes fast path completion possible. A delay(0), a Channel.receive() from a buffered channel that is not empty, an already completed Deferred.await(), none of these need the heavy suspension machinery. They compute the answer and return it synchronously. The overhead of calling a suspend function that does not actually suspend is essentially the overhead of a regular virtual method call plus one reference comparison.
suspendCoroutineUninterceptedOrReturn
CPS does not stop at the function signature. You occasionally need to build a suspension primitive yourself, and the Kotlin standard library exposes a compiler intrinsic for that:
public suspend inline fun <T> suspendCoroutineUninterceptedOrReturn(
crossinline block: (Continuation<T>) -> Any?
): TThis intrinsic gives you the current continuation and lets you decide, inline, whether to return a value immediately or return COROUTINE_SUSPENDED and resume the continuation later. Every higher level primitive (suspendCoroutine, suspendCancellableCoroutine, withContext's fast path, the internals of CompletableDeferred.await()) is built on top of this one intrinsic. When you eventually write your own callback to suspend bridge, this is where the bytecode stops being generated by the Kotlin compiler and starts being generated by you.
The state machine transformation
Once CPS has rewritten the signature, the compiler has to rewrite the body. The body is where suspension actually happens, and the body is where the JVM's "stack frames cannot be paused" limitation forces a real transformation. The answer is a state machine.
Take a small, concrete example. The function has two suspension points, delay(100) and delay(200), and no local variables that need to cross a suspension:
suspend fun greet(): String {
println("Before first delay")
delay(100)
println("Between delays")
delay(200)
return "Done"
}The compiler will produce a continuation class with three labels: state 0 (initial entry), state 1 (resume after the first delay), and state 2 (resume after the second delay). If you dump the Kotlin bytecode through Tools, Kotlin, Show Kotlin Bytecode, Decompile, you get code equivalent to the block below. It is written in Kotlin rather than Java for readability, but the structure matches the generated class almost one to one.
First, the outer function. The prelude either reuses the incoming state machine (if this is a resume) or allocates a new one. Then a when on label dispatches to the right segment:
fun greet($completion: Continuation<String>): Any? {
val $sm: GreetStateMachine =
if ($completion is GreetStateMachine && $completion.label and Int.MIN_VALUE != 0) {
$completion.apply { label = label - Int.MIN_VALUE }
} else {
GreetStateMachine($completion)
}
when ($sm.label) {
0 -> {
$sm.result.throwOnFailure()
println("Before first delay")
$sm.label = 1
val r = delay(100L, $sm)
if (r === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
}
1 -> $sm.result.throwOnFailure()
2 -> $sm.result.throwOnFailure()
else -> throw IllegalStateException("call to 'resume' before 'invoke' with coroutine")
}
when ($sm.label) {
0, 1 -> {
println("Between delays")
$sm.label = 2
val r = delay(200L, $sm)
if (r === COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
}
}
return "Done"
}The generated continuation class is where the state actually lives. It extends ContinuationImpl, which itself extends BaseContinuationImpl, so the runtime can hand this object to a dispatcher and later receive a resumeWith callback on it. The invokeSuspend method is the re entry point: the runtime calls it on resume, and it sets the sign bit on the label before re invoking the original function:
private class GreetStateMachine(
completion: Continuation<String>
) : ContinuationImpl(completion) {
@JvmField var result: Result<Any?> = Result.success(Unit)
@JvmField var label: Int = 0
override fun invokeSuspend(result: Result<Any?>): Any? {
this.result = result
this.label = this.label or Int.MIN_VALUE
return greet(this as Continuation<String>)
}
}That code is worth staring at for a minute before we unpack it.
Anatomy of the generated class
The GreetStateMachine captures the state that must survive across suspensions:
label: Int: which segment of the function body to execute next. 0 is the initial entry, 1 is "after the firstdelay", 2 is "after the seconddelay." For a function with N suspension points, labels 0 through N are used.result: Result<Any?>: the incoming value (or exception) delivered by the previousresumeWithcall. On the very first invocation, this field isResult.success(Unit).- Spill fields for live locals: none in this example, but there would be
L$0,L$1for reference types andI$0,I$1for primitive ints if we had saved any local across a suspension.
This class extends ContinuationImpl, which itself extends BaseContinuationImpl, which implements Continuation<Any?>. The inheritance chain is what lets the runtime hand this object to a dispatcher and later receive a resumeWith callback on it.
The sign bit trick on label
The compiler needs to distinguish three calling scenarios when the function is entered:
- A fresh, outside in call (the caller passes in their own continuation).
- A resume: the runtime called
invokeSuspend, which re entered the function with the state machine object itself as$completion. - A recursive self call: the generated code calls itself to re enter after resume.
Why does this distinction matter? If the function cannot tell "fresh call" from "resume," it does not know whether to allocate a new state machine or reuse the one that just delivered a result. Allocating every time would leak memory and lose the saved state. Reusing every time would clobber a caller's own continuation.
The compiler disambiguates with a single bit. On resume, invokeSuspend ORs Int.MIN_VALUE (the sign bit) into label. The entry prelude then checks $completion is GreetStateMachine && $completion.label and Int.MIN_VALUE != 0. If both are true, this is a resume: clear the bit and reuse the object. Otherwise, it is a fresh call: allocate a new state machine with the incoming $completion as its parent. One integer, three scenarios, no extra fields. The designers chose this route because label already needs to be read on entry (for the switch), so reading one more bit off it costs nothing.
Walking through a suspension
Trace what actually happens when greet() suspends at delay(100):
- The caller enters
greet($completion). The prelude createsGreetStateMachine($completion)because$completionis not our class.labelstarts at 0. - The
whenlands in the0branch. We print "Before first delay" and set$sm.label = 1. - We call
delay(100L, $sm). Insidedelay, the dispatcher schedules a timed callback and returnsCOROUTINE_SUSPENDED. greetseesr === COROUTINE_SUSPENDEDand returns the same sentinel. The JVM frame unwinds.- 100ms later, the scheduled callback calls
$sm.resumeWith(Result.success(Unit)). This lands inBaseContinuationImpl.resumeWith, which calls$sm.invokeSuspend(Result.success(Unit)). invokeSuspendstores the result, ORs the sign bit intolabel, and re invokesgreet(this).- The prelude sees the sign bit is set, clears it (label returns to 1), and reuses the object.
- The outer
whenlands in branch1, callsthrowOnFailure()(the result was a success, so nothing happens), then falls into the secondwhenwhich prints "Between delays" and sets up the nextdelay.
Between steps 4 and 5, there is no JVM thread blocked inside greet. The frame is gone. The only thing alive is the GreetStateMachine heap object, held by whatever scheduler is waiting for the 100ms timer. That is what "suspension" really means.
Variable spilling
The greet example has no live locals across suspension. A slightly more realistic function forces the compiler to save state:
suspend fun loadData(id: Long): Payload {
val token = authenticate()
val payload = fetch(id, token)
return parse(payload)
}Both id and token are alive at the call to fetch. The compiler runs liveness analysis per suspension point and decides which locals need to move from the operand stack onto the continuation object. For loadData, the state machine grows two spill fields: one for the primitive long, one for the object reference.
private class LoadDataStateMachine(completion: Continuation<Payload>) :
ContinuationImpl(completion) {
@JvmField var result: Result<Any?> = Result.success(Unit)
@JvmField var label: Int = 0
@JvmField var J$0: Long = 0L
@JvmField var L$0: Any? = null
}The naming is not arbitrary. L$0, L$1 are for reference types, I$0 for ints, J$0 for longs, D$0 for doubles, F$0 for floats. Typed spill fields let primitives cross a suspension without boxing, which matters for tight inner loops over Int or Long.
Before the second suspend call, the bytecode saves both locals into the continuation:
aload this_sm
lload id
putfield LoadDataStateMachine.J$0 : J
aload this_sm
aload token
putfield LoadDataStateMachine.L$0 : ObjectAfter the resume label, it reads them back out into locals so the rest of the function can treat them as normal variables:
aload this_sm
getfield LoadDataStateMachine.J$0 : J
lstore id
aload this_sm
getfield LoadDataStateMachine.L$0 : Object
astore tokenTwo subtle things are worth calling out. First, reference spills are nulled out on the continuation after being restored into locals. If the coroutine suspends for a long time in a later segment, we do not want LoadDataStateMachine.L$0 to pin an already consumed token object in memory. Second, the compiler only spills variables that are actually live across a suspension. A val x = heavyCompute() that is read and discarded between two suspensions never touches the continuation.
This is the practical reason why an oversized Bitmap captured before a long delay can feel like a memory leak. The compiler stored it in a spill field, the coroutine is parked, and the heap holds on to it until the coroutine either completes or overwrites the field. Nulling the local yourself, or moving the allocation into a shorter scoped function, fixes it.
Tail call optimization
If every suspension point in a function is a tail call, meaning the suspend call's return value is the return value of the enclosing function with no further computation, the compiler skips the state machine entirely. No continuation class, no label, no switch. It just forwards the caller's continuation directly:
suspend fun findUserByEmail(email: String): User? =
userRepo.find(email)The compiled form is almost identical to a regular delegating method. Its only overhead over a direct call is the COROUTINE_SUSPENDED check in case userRepo.find returns the sentinel. The compiler's allSuspensionPointsAreTailCalls pass verifies that no try/catch, no finally, and no further operations wrap the suspend call before it falls through to the return. When that holds, suspend is almost free. Wrap the same body in try { ... } catch (...), or add a log(...) line after the suspend call, and the optimization disappears. A full state machine comes back.
BaseContinuationImpl and the trampoline
The compiler generates state machines. The standard library drives them. Every generated ContinuationImpl ultimately inherits from BaseContinuationImpl, defined in kotlin.coroutines.jvm.internal. The class is small, but its resumeWith is one of the most important methods in the entire coroutine runtime.
Here is what the method looks like if you examine BaseContinuationImpl in the standard library:
internal abstract class BaseContinuationImpl(
public val completion: Continuation<Any?>?
) : Continuation<Any?>, CoroutineStackFrame, Serializable {
public final override fun resumeWith(result: Result<Any?>) {
var current: BaseContinuationImpl = this
var param: Result<Any?> = result
while (true) {
with(current) {
val completion = completion!!
val outcome: Result<Any?> =
try {
val outcome = invokeSuspend(param)
if (outcome === COROUTINE_SUSPENDED) return
Result.success(outcome)
} catch (exception: Throwable) {
Result.failure(exception)
}
releaseIntercepted()
if (completion is BaseContinuationImpl) {
current = completion
param = outcome
} else {
completion.resumeWith(outcome)
return
}
}
}
}
}Read the loop slowly, because it is doing several things at once. Each iteration invokes invokeSuspend(param) on the current continuation. That method is the generated state machine's re entry point, and it runs the next segment of user code until it either hits a new suspension (returning COROUTINE_SUSPENDED) or runs off the end of the function (returning the real result, or throwing).
If the segment returned COROUTINE_SUSPENDED, the coroutine paused again and the loop exits. We go back to waiting for whatever event will resumeWith next. Otherwise the segment completed, either successfully or by throwing. The loop wraps the outcome in a Result and looks at completion: the parent continuation, the one that should be woken up with our result.
Here is the trampoline. If completion is itself a BaseContinuationImpl, instead of calling completion.resumeWith(outcome) recursively, the loop rewrites its own variables. current = completion, param = outcome, and it loops back to the top. What would have been a recursive resumeWith → invokeSuspend → resumeWith → invokeSuspend → ... cascade collapses into a single while (true) stack frame.
Without this trick, deeply nested suspend chains (think a Flow with 30 operators, each synchronously emitting to the next) would grow the JVM stack linearly with the chain depth, and a stack overflow would be just one flatMapConcat away. With the trampoline, the stack depth is bounded by the dispatcher invocation regardless of how many coroutines resume synchronously in a row.
The releaseIntercepted() call at the bottom of each iteration lets the dispatcher clean up the cached DispatchedContinuation for this frame. The loop only exits through three doors: a segment suspended again, the completion is not a BaseContinuationImpl so we forward to it and return, or invokeSuspend threw and we forwarded the failure upward. All three outcomes are fine. None of them grow the stack.
ContinuationImpl and the interceptor cache
ContinuationImpl extends BaseContinuationImpl and adds one small but important responsibility. It caches the intercepted continuation so the dispatcher wrapper does not have to be rebuilt on every resume:
internal abstract class ContinuationImpl(
completion: Continuation<Any?>?,
private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {
public override val context: CoroutineContext = _context!!
@Transient
private var intercepted: Continuation<Any?>? = null
public fun intercepted(): Continuation<Any?> =
intercepted
?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
.also { intercepted = it }
protected override fun releaseIntercepted() {
val intercepted = intercepted
if (intercepted != null && intercepted !== this) {
context[ContinuationInterceptor]!!.releaseInterceptedContinuation(intercepted)
}
this.intercepted = CompletedContinuation
}
}intercepted() pulls the ContinuationInterceptor (the dispatcher) out of the context and asks it to wrap this (the raw state machine object) into a dispatch aware continuation. For CoroutineDispatcher, that wrapper is a DispatchedContinuation. The result is cached on the instance. Rebuilding the wrapper from scratch every resume would be a real hot path allocation, because intercepted() runs every time the coroutine is resumed.
Context, dispatch, and resumption
The Continuation contract does not say anything about threads. Switching threads is a library concern, bolted on top of the language primitives through ContinuationInterceptor. A dispatcher is an interceptor that returns a DispatchedContinuation for every continuation it sees.
If you examine CoroutineDispatcher in kotlinx.coroutines:
public abstract class CoroutineDispatcher :
AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true
public abstract fun dispatch(context: CoroutineContext, block: Runnable)
public final override fun <T> interceptContinuation(
continuation: Continuation<T>
): Continuation<T> = DispatchedContinuation(this, continuation)
}The interesting logic is not in CoroutineDispatcher itself but in the wrapper it returns. DispatchedContinuation is where the "switch threads on resume" decision lives:
internal class DispatchedContinuation<in T>(
@JvmField internal val dispatcher: CoroutineDispatcher,
@JvmField val continuation: Continuation<T>
) : DispatchedTask<T>(MODE_UNINITIALIZED),
CoroutineStackFrame,
Continuation<T> by continuation {
override fun resumeWith(result: Result<T>) {
val state = result.toState()
if (dispatcher.safeIsDispatchNeeded(context)) {
_state = state
resumeMode = MODE_ATOMIC
dispatcher.safeDispatch(context, this)
} else {
executeUnconfined(state, MODE_ATOMIC) {
withCoroutineContext(context, countOrElement) {
continuation.resumeWith(result)
}
}
}
}
}Two branches. If the dispatcher says a dispatch is needed (we are not already on the correct thread), the DispatchedContinuation stashes the result in _state and submits itself (it is a Runnable through DispatchedTask) to the dispatcher. When the worker thread pops it off the queue and calls run(), it will unpack _state and finally call continuation.resumeWith(result) from the right thread.
Otherwise, resumeWith runs the underlying continuation directly on the current thread. This is the undispatched path that coroutineScope, withContext on the same dispatcher, and Dispatchers.Unconfined all take. From the compiler's perspective, none of this exists. The compiler produces code that calls resumeWith on a Continuation. Whether that Continuation is a DispatchedContinuation pointing at Dispatchers.Main or a raw state machine running inline is the dispatcher's concern, not the language's.
Practical Kotlin Deep Dive
492 pages, 70 deep-dive topics, from coroutines to compiler internals. By Jaewoong Eum, Google Developer Expert.
Exception handling through continuations
The resumeWith(Result<T>) signature is not an accident. Every resumption, success or failure, travels through the same envelope, and that is how exceptions flow through a coroutine chain without an explicit try/catch hand off.
When an async callback fails, it calls continuation.resumeWith(Result.failure(exception)). That lands in BaseContinuationImpl.resumeWith, which calls invokeSuspend(param) with the failed Result. The generated state machine's very first statement in each resume branch is a single call:
$sm.result.throwOnFailure()throwOnFailure is a small extension that throws the stored exception if the result is a failure. The exception an async callback reported ends up being thrown inside the suspend function's body, at the line after the suspension point, exactly as if the suspend call had thrown synchronously.
That thrown exception now behaves like any other Kotlin exception. If the code around the suspension has a surrounding try/catch, that catch block will handle it. This is why try { withContext(IO) { ... } } catch (e: SQLException) { ... } works. If there is no surrounding handler, the exception propagates out of invokeSuspend. Back up in BaseContinuationImpl.resumeWith, the try/catch around invokeSuspend wraps it in Result.failure(exception) and the loop forwards that failure to the parent continuation, which goes through its own resume path, its own throwOnFailure, and so on.
At the top of the chain, the final completion is usually an AbstractCoroutine, the Job itself. AbstractCoroutine.resumeWith converts the Result into the job's terminal state: a CompletedExceptionally if it is a failure. From there, structured concurrency takes over. The exception is either delivered to the CoroutineExceptionHandler (for launch) or stored for later rethrow from await() (for async).
The important property of this design is that the compiler never sees exceptions specially. The generated state machine treats throwOnFailure as a normal Kotlin throw, and the rest of the body uses regular Kotlin exception handling. The only requirement is that resumeWith faithfully delivers a Result.failure(...) on the async error path.
Common misconceptions about suspend function internals
These misconceptions appear repeatedly in code reviews and interviews. Keep them pinned.
- "
suspendmeans async." It does not.suspendis a marker that enables CPS and the state machine transformation. Asuspend funcan complete synchronously, never touch another thread, and never actually pause. The fast path throughCOROUTINE_SUSPENDEDis common:Channel.receive()on a non empty buffered channel,StateFlow.first()when the value already matches,Deferred.await()on an already completed deferred. - "Coroutines are lightweight threads." They are not threads at all. A coroutine is a heap object (a
Jobplus a linked list of state machine continuations) that happens to run on whatever thread the dispatcher pulls it onto. The "thread like" analogy breaks the moment you try to reason about memory, cancellation, or thread local state. - "
delay(1000)blocks a thread for a second." No.delayregisters a timed callback with the dispatcher's event loop and returnsCOROUTINE_SUSPENDED. The thread is free to run other coroutines for the next second. This is why you can have a million simultaneously delaying coroutines on a thread pool of size 8. - "I can just call a suspend function from Java." Not without extra work. CPS transformed suspend functions have an extra
Continuationparameter and returnAny?. The conventional fix is to expose a non-suspend adapter: aCompletableFuturewrapper viafuture { ... }, a callback, orrunBlockingfor command line code. - "The state machine allocates once per suspension." It allocates once per invocation, not once per suspension. The same
ContinuationImplobject persists for the whole life of a single call, no matter how many times that function suspends. Local variables live on fields of that one object. - "Coroutines require
kotlinx.coroutines." Thekotlin.coroutineslanguage primitives (suspend,Continuation,CoroutineContext,startCoroutine,suspendCoroutine) live in the standard library and are enough to implement your own sequential generator.kotlinx.coroutinesis a library built on top of those primitives. It is technically optional.
Inspecting suspend function internals yourself
Nothing in this article is as convincing as looking at the bytecode for one of your own functions. In IntelliJ IDEA or Android Studio:
- Open any
.ktfile that declares asuspend fun. - Pick Tools, Kotlin, Show Kotlin Bytecode.
- In the bytecode tool window, click Decompile in the top bar.
You get a Java equivalent reconstruction. Scroll down and you will see the original signature rewritten with a Continuation parameter and Object return type, a generated inner class extending ContinuationImpl with int label; and Object L$0; style spill fields, the invokeSuspend(Object) method and the switch statement dispatching on label, and the sign bit check at the top of the outer function. Uncheck Decompile to Java for the raw instruction listing with TABLESWITCH, PUTFIELD, and GETFIELD spelled out.
For a deeper source level inspection, kotlinx.coroutines ships a debug agent (kotlinx-coroutines-debug) and an IntelliJ coroutines debugger. Both use the CoroutineStackFrame interface that BaseContinuationImpl implements to reconstruct logical "coroutine stacks" from the linked list of continuations, which is how the IDE shows you the suspend call hierarchy even after the JVM has unwound the real stack.
Where to go next
The story does not end with Continuation and the state machine. The next layer up is the runtime library: how Job maintains its lock free state machine for lifecycle and cancellation, how structured concurrency propagates cancellation up the parent chain and exceptions back down, how CoroutineScheduler implements work stealing across Dispatchers.Default and Dispatchers.IO, how Flow, SharedFlow, and StateFlow compose without buffering when they do not have to.
Practical Kotlin Deep Dive · Course
26 hands-on Code Playgrounds, 158 interactive assessments, certificate of completion. The same content as the book, designed for active practice.
Explore the course →For related reading, see Kotlin coroutines explained for the library level story of Job, dispatchers, and structured concurrency, and Flow vs LiveData if you want to see how Flow, itself a thin wrapper around suspend, compares to Android's older reactive primitive.
The transformations in this article are taken directly from the kotlin.coroutines standard library and the Kotlin compiler repository, and the runtime from the kotlinx.coroutines GitHub repository. If any detail ever looks odd in a future Kotlin release, those two repositories are the final word.
As always, happy coding!
— Jaewoong (skydoves)
Frequently asked questions
What is a Continuation in Kotlin?â–¾
A Continuation is a standard library interface with two members: a CoroutineContext and a resumeWith(Result<T>) method. It represents everything that should happen after a suspension point. The compiler rewrites every suspend function so that it takes a Continuation as its last parameter, and the runtime invokes resumeWith to deliver a value or exception when the suspended work finishes.
What does the suspend keyword actually do?â–¾
The suspend keyword has no runtime behavior on its own. It is a marker that tells the compiler to apply Continuation-Passing Style transformation: append a hidden Continuation parameter, change the return type to Any?, and rewrite the body into a state machine with a label field and switch. Nothing about threads, async, or dispatchers is implied by the keyword itself.
What is COROUTINE_SUSPENDED?â–¾
COROUTINE_SUSPENDED is a sentinel object defined in kotlin.coroutines.intrinsics. Every compiled suspend function returns either the real result or this sentinel. Returning the sentinel means the function has paused and will deliver its result later through its Continuation; any other return value means the function finished synchronously (the fast path).
How do local variables survive across suspension in Kotlin?â–¾
The compiler performs liveness analysis and promotes variables that are alive across a suspension point to fields on the generated continuation class. These are known as spill fields (L$0, L$1 for objects, I$0 for ints, and so on). When the coroutine resumes, the state machine reads these fields back into locals before executing the next segment.
Why does Kotlin use a state machine instead of real stack saving?â–¾
The JVM has no portable way to capture and later restore an arbitrary stack frame. A state machine compiled entirely to normal methods and fields works on any JVM, on Android, on Kotlin/JS, and on Kotlin/Native without a runtime assist. It also lets the HotSpot JIT inline and escape-analyze the generated code the same way it handles ordinary Kotlin.
What is the trampoline in BaseContinuationImpl?â–¾
BaseContinuationImpl.resumeWith contains a while(true) loop that walks from the current continuation to its completion, calling invokeSuspend and forwarding the result. Because the loop replaces what would otherwise be recursive resumeWith calls, deeply chained coroutines cannot blow the JVM stack no matter how many suspension points resume synchronously in a row.
Can I call a Kotlin suspend function from Java?â–¾
Not directly. After CPS transformation, the bytecode signature has an extra Continuation parameter and returns Any?. You can call it from Java by providing a Continuation implementation, but the ergonomics are poor. In practice, you expose a non-suspend bridge (a CompletableFuture, a callback, or runBlocking) for Java callers.
How can I see the state machine for my own suspend function?â–¾
In IntelliJ IDEA or Android Studio, open the Kotlin file, then use Tools, Kotlin, Show Kotlin Bytecode, and click Decompile. The resulting Java-equivalent code makes the generated continuation class, the label field, the switch statement, and the spill fields visible.
Related reading
Kotlin Coroutines Explained: Suspend, Scope & Structured Concurrency
A pragmatic tour of Kotlin coroutines: suspend, builders, scope, Job, dispatchers, cancellation, how it all hangs together.
Kotlin Flow vs LiveData: Which to Pick in 2026
When StateFlow wins, when LiveData is still the right call, and how to migrate without breaking production.
Practical Kotlin Deep Dive
492 pages, 70 deep-dive topics, from coroutines to compiler internals. By Jaewoong Eum, Google Developer Expert.


