How Kotlin Coroutines Work Internally

Here's a complete blog article on how Kotlin coroutines work internally:
How Kotlin Coroutines Work Internally — A Complete Guide
If you've been using Kotlin coroutines, you've probably wondered what actually happens when a suspend function suspends. The answer is surprisingly elegant — and it's all done by the compiler, not the JVM or the OS.
Let's walk through the whole thing, from the keyword to the decompiled bytecode.
Start With the Simple Code
Here's a basic suspend function:
suspend fun myFunction() {
println("Before")
delay(1000)
println("After")
}
This looks like it just waits one second between two print statements. But under the hood, the Kotlin compiler completely rewrites this function. Let's find out how.
Rule #1: suspend Is a Compiler Feature, Not a JVM Feature
The JVM has no idea what a coroutine is. There's no special bytecode instruction for suspension. No OS fiber. No magic thread.
The suspend keyword is purely a signal to the Kotlin compiler that says: "Transform this function into a state machine."
That's it. Everything else — suspension, resumption, state storage — comes from that transformation.
What the Compiler Does: CPS Transformation
The compiler applies something called Continuation-Passing Style (CPS). Your function:
suspend fun myFunction()
Becomes this at the JVM level:
Object myFunction(Continuation $completion)
Two things changed. First, a hidden Continuation parameter was added. Second, the return type became Object instead of Unit. Both of these are intentional, and we'll explain why shortly.
What Is a Continuation?
A Continuation is an object that represents what to do next. It's the mechanism that allows the function to resume after it paused.
The interface looks like this:
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
Think of it like a bookmark. When the function pauses, it hands off this bookmark to whoever is responsible for waking it up later. When the async work finishes, that bookmark is used to jump back to exactly the right place.
The State Machine — The Core Idea
Here's the critical transformation. Your original function has one suspension point — delay(). The compiler uses that to split the function into numbered states.
State 0 = everything before
delay()State 1 = everything after
delay()
The function body becomes a switch statement that jumps to the right state each time it's called:
switch ($continuation.label) {
case 0:
// First call — run the "Before" part
case 1:
// Resumed — run the "After" part
}
A variable called label stored inside the continuation object tracks which state we're in. That's the entire secret to how the function "remembers" where it was.
Walking Through the Actual Decompiled Code
Here's the real decompiled output from the Kotlin compiler:
public static final Object myFunction(Continuation $completion) {
Continuation $continuation;
label20: {
if ($completion instanceof <undefinedtype>) {
$continuation = $completion;
if (($continuation.label & Integer.MIN_VALUE) != 0) {
$continuation.label -= Integer.MIN_VALUE;
break label20;
}
}
$continuation = new ContinuationImpl($completion) {
Object result;
int label;
public final Object invokeSuspend(Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return myFunction(this);
}
};
}
Object $result = $continuation.result;
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch ($continuation.label) {
case 0:
ResultKt.throwOnFailure($result);
System.out.println("Before");
$continuation.label = 1;
if (DelayKt.delay(1000L, $continuation) == var3) {
return var3;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke'");
}
System.out.println("After");
return Unit.INSTANCE;
}
Let's go through each part.
Part 1: The Continuation Reuse Check
if ($completion instanceof <undefinedtype>) {
$continuation = $completion;
if (($continuation.label & Integer.MIN_VALUE) != 0) {
$continuation.label -= Integer.MIN_VALUE;
break label20;
}
}
When myFunction is first called, $completion is the parent's continuation — something external. But when it resumes, it's called again with the same ContinuationImpl object that was created during the first call. This check detects that situation.
If the Integer.MIN_VALUE bit is set on the label, it means we're resuming, not starting fresh. So we skip creating a new object and reuse the existing one. This avoids an unnecessary heap allocation on every resume.
Part 2: The ContinuationImpl Object
$continuation = new ContinuationImpl($completion) {
Object result;
int label;
public final Object invokeSuspend(Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return myFunction(this);
}
};
This anonymous class is the state machine. The compiler generates one of these for each suspend function. It holds:
label— the current execution state (whichcaseto jump to)result— the value passed back when the coroutine resumesThe parent
$completion— used to propagate the final result upward when doneinvokeSuspend()— the method the runtime calls to re-enter the function after suspension
When invokeSuspend is called, it sets the Integer.MIN_VALUE bit on the label (the resume flag), then calls myFunction(this) — passing itself as the continuation. The function re-enters, the reuse check detects the flag, strips it off, and the switch jumps to the right case.
Part 3: COROUTINE_SUSPENDED
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
This is a singleton sentinel object. The entire suspension mechanism hinges on this one value.
If a suspend function returns this object, it means: "I have paused. Don't call my caller yet. I'll resume myself through the continuation when I'm ready."
The check later is just a reference equality comparison:
if (DelayKt.delay(1000L, $continuation) == var3) {
return var3;
}
No boxing, no method calls — just a pointer comparison. It's essentially free at runtime.
Part 4: The Switch Statement — First Call (label = 0)
case 0:
ResultKt.throwOnFailure($result);
System.out.println("Before");
$continuation.label = 1;
if (DelayKt.delay(1000L, $continuation) == var3) {
return var3;
}
break;
On the first call, label is 0, so we land here. The steps are:
throwOnFailurechecks if the previous result was an error (it's null on first call, so nothing happens)Print "Before"
Set
label = 1so the next call lands in case 1Call
delay(1000L, $continuation)— passing our state machine object as the continuationIf delay returns
COROUTINE_SUSPENDED, return it immediately — the thread is now free
At this point the function has exited. The stack frame is gone. But the state machine object is still alive on the heap, holding label = 1 and waiting.
Part 5: What delay() Does Internally
When delay(1000L, $continuation) is called, it does something like this under the hood:
suspendCancellableCoroutine { cont ->
scheduleOnEventLoop(1000) {
cont.resume(Unit)
}
}
It registers a callback with the event loop (or a scheduled executor), then returns COROUTINE_SUSPENDED. No thread is blocked. The event loop just holds a reference to the continuation and calls it after 1 second.
Part 6: Resume — After 1 Second
After the timer fires, the event loop calls continuation.resumeWith(Result.success(Unit)). This eventually reaches invokeSuspend(Unit) on our ContinuationImpl object, which calls myFunction(this) again.
The function re-enters. The reuse check detects the resume flag. The switch lands on:
case 1:
ResultKt.throwOnFailure($result);
break;
throwOnFailure checks if anything went wrong during the suspension (like a cancellation). If all is fine, we fall through to:
System.out.println("After");
return Unit.INSTANCE;
The function completes. Unit.INSTANCE is returned up the chain, and the coroutine is done.
Why the Return Type Is Object
A suspend function has two possible outcomes on any given call:
It completes immediately and returns a real value (like
Unit.INSTANCE)It suspends and returns
COROUTINE_SUSPENDED
The JVM's type system can't express "this returns either T or a sentinel" in a single typed return. So the compiler widens the return type to Object and lets the caller check at runtime which case occurred.
Where All the State Lives
When a coroutine is suspended, there is no stack frame. The JVM stack is clean. All the state that needs to survive the suspension — local variables, the current label, any intermediate values — is stored inside the ContinuationImpl object on the heap.
This is why coroutines are so cheap compared to threads. A thread requires an OS-allocated stack (typically 512KB–1MB). A suspended coroutine is just a small heap object, typically a few hundred bytes.
The Complete Execution Timeline
Here's the full sequence from start to finish:
myFunction()is called by the coroutine runtimeA new
ContinuationImplis created (label = 0)The switch lands on case 0
"Before" is printed
label is set to 1
delay(1000L, continuation)is calleddelay registers a timer and returns
COROUTINE_SUSPENDEDmyFunctionreturnsCOROUTINE_SUSPENDEDto the runtimeThe thread is released back to the thread pool
One second later, the timer fires and calls
continuation.resumeWith(Unit)invokeSuspendis called, which callsmyFunction(this)againThe reuse check detects we're resuming, strips the flag
The switch lands on case 1
"After" is printed
Unit.INSTANCEis returned — coroutine complete
The Key Concepts, Summarized
suspend function — not a special runtime feature, just a compiler signal to generate a state machine
ContinuationImpl — the heap object that holds the current label, result, and any surviving local variables
label — an integer that acts as the execution pointer, telling the switch where to jump on the next call
COROUTINE_SUSPENDED — a singleton sentinel; returning it means "I've paused, resume me via the continuation"
invokeSuspend — the entry point the runtime uses to re-enter the function after suspension
throwOnFailure — checks for errors or cancellation on each resume, propagating them as exceptions if present
What suspend Does NOT Do
It's worth being explicit about this, because the mental model matters:
A suspend function does not create a coroutine. It does not create a thread. It does not schedule anything. It does not automatically run in the background.
A suspend function is just a building block. The coroutine itself — the thing that actually runs and manages the lifecycle — is created by launch, async, runBlocking, or coroutineScope. Those builders provide the initial Continuation and the dispatcher that decides which thread to run on.
The suspend function just knows how to pause and resume. Everything else is the responsibility of the coroutine infrastructure around it.
Mental Model
A suspend function is a regular JVM function that has been rewritten into a resumable switch-case state machine. Every suspension point becomes a numbered case. The current case number is stored in a heap object. Suspension means returning a special marker. Resumption means calling the function again and jumping to the saved case.
No magic. No OS involvement. No threads created. Just a compiler transformation and a heap object.
That's all Kotlin coroutines are at the bottom — and that simplicity is exactly what makes them so powerful.
Comments (0)
No comments yet. Be the first to share your thoughts!
Leave a Comment
Get the latest updates
Join our newsletter and never miss an article.