Tutorials

    How Kotlin Coroutines Work Internally

    A
    AzeemFeb 27, 2026 · 9 min read
    511
    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")
    }
    
    kotlin

    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()
    
    kotlin

    Becomes this at the JVM level:

    Object myFunction(Continuation $completion)
    
    java

    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>)
    }
    
    kotlin

    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
    }
    
    java

    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;
    }
    
    java

    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;
        }
    }
    
    java

    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);
        }
    };
    
    java

    This anonymous class is the state machine. The compiler generates one of these for each suspend function. It holds:

    • label — the current execution state (which case to jump to)

    • result — the value passed back when the coroutine resumes

    • The parent $completion — used to propagate the final result upward when done

    • invokeSuspend() — 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();
    
    java

    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;
    }
    
    java

    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;
    
    java

    On the first call, label is 0, so we land here. The steps are:

    1. throwOnFailure checks if the previous result was an error (it's null on first call, so nothing happens)

    2. Print "Before"

    3. Set label = 1 so the next call lands in case 1

    4. Call delay(1000L, $continuation) — passing our state machine object as the continuation

    5. If 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)
        }
    }
    
    kotlin

    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;
    
    java

    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;
    
    java

    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:

    1. myFunction() is called by the coroutine runtime

    2. A new ContinuationImpl is created (label = 0)

    3. The switch lands on case 0

    4. "Before" is printed

    5. label is set to 1

    6. delay(1000L, continuation) is called

    7. delay registers a timer and returns COROUTINE_SUSPENDED

    8. myFunction returns COROUTINE_SUSPENDED to the runtime

    9. The thread is released back to the thread pool

    10. One second later, the timer fires and calls continuation.resumeWith(Unit)

    11. invokeSuspend is called, which calls myFunction(this) again

    12. The reuse check detects we're resuming, strips the flag

    13. The switch lands on case 1

    14. "After" is printed

    15. Unit.INSTANCE is 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.

    kotlin
    android
    kotlin coroutines
    suspend function
    coroutine internals
    state machine
    continuation
    async await
    structured concurrency
    kotlin compiler
    Was this helpful?

    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.

    By subscribing, you agree to our Privacy Policy and consent to receive updates.

    Ready to Start Learning?

    Join thousands of students building their dream careers

    Explore Courses