Practical Kotlin Deep Dive · Guide

Kotlin Suspend Function Internals: Continuations & State Machines

A deep dive into how the Kotlin compiler transforms suspend functions into resumable state machines, and how Continuation makes suspension possible.

Jaewoong Eum
Jaewoong Eum (skydoves)

Google Developer Expert · April 22, 2026 · 13 min read

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(): Int is compiled into fun 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 ContinuationImpl subclass with a label: Int field, spill fields for live locals, and an invokeSuspend method containing a switch(label).
  • Returning COROUTINE_SUSPENDED means the function paused and will deliver its result later through its continuation. Returning anything else is the synchronous fast path.
  • BaseContinuationImpl.resumeWith uses 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 User result, when the function was able to complete synchronously. This is the fast path.
  • The singleton COROUTINE_SUSPENDED sentinel, 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_SUSPENDED

Every 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?
): T

This 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 first delay", 2 is "after the second delay." For a function with N suspension points, labels 0 through N are used.
  • result: Result<Any?>: the incoming value (or exception) delivered by the previous resumeWith call. On the very first invocation, this field is Result.success(Unit).
  • Spill fields for live locals: none in this example, but there would be L$0, L$1 for reference types and I$0, I$1 for 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:

  1. A fresh, outside in call (the caller passes in their own continuation).
  2. A resume: the runtime called invokeSuspend, which re entered the function with the state machine object itself as $completion.
  3. 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):

  1. The caller enters greet($completion). The prelude creates GreetStateMachine($completion) because $completion is not our class. label starts at 0.
  2. The when lands in the 0 branch. We print "Before first delay" and set $sm.label = 1.
  3. We call delay(100L, $sm). Inside delay, the dispatcher schedules a timed callback and returns COROUTINE_SUSPENDED.
  4. greet sees r === COROUTINE_SUSPENDED and returns the same sentinel. The JVM frame unwinds.
  5. 100ms later, the scheduled callback calls $sm.resumeWith(Result.success(Unit)). This lands in BaseContinuationImpl.resumeWith, which calls $sm.invokeSuspend(Result.success(Unit)).
  6. invokeSuspend stores the result, ORs the sign bit into label, and re invokes greet(this).
  7. The prelude sees the sign bit is set, clears it (label returns to 1), and reuses the object.
  8. The outer when lands in branch 1, calls throwOnFailure() (the result was a success, so nothing happens), then falls into the second when which prints "Between delays" and sets up the next delay.

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 : Object

After 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 token

Two 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.

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.

  • "suspend means async." It does not. suspend is a marker that enables CPS and the state machine transformation. A suspend fun can complete synchronously, never touch another thread, and never actually pause. The fast path through COROUTINE_SUSPENDED is 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 Job plus 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. delay registers a timed callback with the dispatcher's event loop and returns COROUTINE_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 Continuation parameter and return Any?. The conventional fix is to expose a non-suspend adapter: a CompletableFuture wrapper via future { ... }, a callback, or runBlocking for command line code.
  • "The state machine allocates once per suspension." It allocates once per invocation, not once per suspension. The same ContinuationImpl object 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." The kotlin.coroutines language primitives (suspend, Continuation, CoroutineContext, startCoroutine, suspendCoroutine) live in the standard library and are enough to implement your own sequential generator. kotlinx.coroutines is 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:

  1. Open any .kt file that declares a suspend fun.
  2. Pick Tools, Kotlin, Show Kotlin Bytecode.
  3. 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.

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.