Practical Kotlin Deep Dive · Free Chapter

Kotlin Coroutines: Free Chapter Preview (PDF)

Read the opening of the Coroutines chapter from Practical Kotlin Deep Dive for free. Get the full 70-page chapter PDF by entering your email.

Jaewoong Eum
Jaewoong Eum (skydoves)

Google Developer Expert · April 22, 2026

You write launch { api.load() } inside a ViewModel and it works. You wrote it from memory, because every tutorial uses that shape. But if someone asks what launch returns, what cancelling it actually does to the in flight network call, or why the suspend keyword is not enough on its own to make a function asynchronous, the mental model gets thin fast. That thinness is the gap between shipping coroutine code and trusting it in production, and it is the gap this chapter is written to close.

In this article, you'll read the opening of the Coroutines chapter of Practical Kotlin Deep Dive. You'll see why coroutines exist, what happens when you write your first one, how the four main builders differ from each other, and what the suspend keyword actually asks the compiler to do. By the end of the preview you'll have a working model of what runs where and who is holding which thread at each moment.

What this chapter covers

The Coroutines chapter is the longest in the book, and it is designed to be read front to back. It begins at the first launch { delay(1000) } and ends with you reading a decompiled state machine without flinching. The goal is not to memorise APIs. The goal is to stop treating coroutines as a black box and start seeing them as what they are: a cooperative scheduling library layered on top of a compile time rewrite you can read instruction by instruction.

This preview covers roughly the first third of that arc. You'll move from the motivation (why the language designers chose suspension over threads), through your first real coroutine program, into what the compiler does with the suspend keyword, and end on a tour of the four builders you will reach for every day. Everything after that, context, Job, scope, dispatchers, channels, cancellation, and the entire Flow family, lives in the full chapter.

Why coroutines exist

Before kotlinx.coroutines existed, writing asynchronous code on the JVM meant choosing between three flawed options. You could block a thread and wait for the result. You could hand a callback to an executor and let someone call you back later. You could compose futures, either CompletableFuture on the JVM or Observable chains from RxJava. Each of these works. Each has a cost.

Threads are expensive. On a typical JVM a thread costs around one megabyte of stack space, plus the kernel level bookkeeping that every context switch drags with it. If you have a hundred tasks waiting on network responses, you pay for a hundred idle stacks sitting in memory while the kernel scheduler rotates through them. Callbacks are cheap in memory but expensive in readability. Here is the shape they take once you have more than one sequential call:

fun loadUserScreen(
    id: String,
    onDone: (Screen) -> Unit,
    onError: (Throwable) -> Unit,
) {
    api.fetchProfile(id, object : Callback<Profile> {
        override fun onSuccess(profile: Profile) {
            api.fetchFriends(id, object : Callback<List<Friend>> {
                override fun onSuccess(friends: List<Friend>) {
                    onDone(Screen(profile, friends))
                }
                override fun onFailure(t: Throwable) = onError(t)
            })
        }
        override fun onFailure(t: Throwable) = onError(t)
    })
}

Two sequential operations, and you have already built a small pyramid. Error handling is duplicated across two anonymous classes. The call site has to understand the difference between the success and failure paths. Adding a third call means another level of nesting, and running two of these in parallel means rewriting the whole structure.

Here is the coroutine version of the same function:

suspend fun loadUserScreen(id: String): Screen {
    val profile = api.fetchProfile(id)
    val friends = api.fetchFriends(id)
    return Screen(profile, friends)
}

This is not a trick of syntax. At runtime neither call blocks a thread. fetchProfile suspends, the thread returns to its dispatcher, and when the network response arrives the function resumes on whatever thread the dispatcher gives it. The shape of the code matches the shape of the logic, sequential, top to bottom, with exceptions that propagate through ordinary try/catch. That is the promise coroutines deliver, and the rest of this chapter explains how they deliver it without smoke or mirrors.

Your first coroutine

The smallest useful coroutine program on the JVM looks like this:

import kotlinx.coroutines.*
 
fun main() = runBlocking {
    launch {
        delay(1000L)
        println("Hello from Coroutine!")
    }
    println("Hello from Main!")
}

If you run it, you see:

Hello from Main!
Hello from Coroutine!

Four lines, and a lot going on underneath. Let's walk through each piece.

runBlocking is a coroutine builder that bridges the regular, non coroutine world (your main function) and the coroutine world. It starts a coroutine on the calling thread and blocks that thread until the coroutine and all of its children have finished. You will see runBlocking in examples, in unit tests, and at the entry point of CLI programs. You will not see it in production Android or server code, because blocking a thread is exactly what coroutines exist to avoid. Think of it as the on ramp from the blocking world into the suspending one.

launch { ... } is another coroutine builder. It starts a new coroutine concurrently with the code around it and returns a Job, which is a handle you can use to wait on the coroutine with join() or stop it with cancel(). The block passed to launch is a suspending lambda, which is how you know it is allowed to call suspend functions like delay inside. The important property of launch is that it does not wait for the block to complete before returning. It schedules the work and moves on.

delay(1000L) is a suspending function. It looks like Thread.sleep, but it behaves nothing like it. delay does not block the thread. Instead, it schedules a resume callback for one second later and immediately suspends the coroutine, returning the underlying thread to the dispatcher so other work can run. That single distinction, suspension is not blocking, is the most important shift in your mental model when moving from threads to coroutines.

So here is the actual sequence of events. runBlocking starts on the main thread and pushes its body onto that thread. Inside the body, launch schedules the child coroutine and returns immediately. The child starts, hits delay(1000L), and suspends. Control returns to the outer coroutine, which prints "Hello from Main!". runBlocking then waits for its children to finish before releasing the thread. One second later, a timer wakes the child up, its continuation runs, it prints its line, and the program exits.

You could replace launch with a thousand launch blocks in a loop and the main thread would still never waste a cycle sitting idle. That is the scaling property coroutines are built for.

What suspend actually asks of the compiler

The suspend keyword is the one thing Kotlin adds to the language itself for coroutines. Everything else, launch, async, Job, Flow, runBlocking, lives in the standard library. Understanding what suspend actually does is the single largest unlock in the chapter, and it is why experienced developers eventually stop being surprised by coroutine behaviour.

Here is the short version. A suspending function like this:

suspend fun fetchUser(id: String): User {
    val profile = fetchProfile(id)
    val friends = fetchFriends(id)
    return User(profile, friends)
}

is rewritten by the compiler into something whose signature, approximately, looks like this:

fun fetchUser(id: String, continuation: Continuation<User>): Any?

Two things changed. First, the return type is now Any?. The function can return the actual User, or it can return a sentinel value called COROUTINE_SUSPENDED that means "I am not done, call me back when the awaited work finishes." Second, an extra parameter appeared at the end: a Continuation.

A Continuation<T> is the smallest possible callback interface:

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

One property and one method. The context carries the dispatcher, the Job, and anything else the coroutine needs to know about its environment. The resumeWith method is the single callback the rest of the system uses to wake the function back up.

The body of fetchUser is rewritten too. Each call to another suspending function becomes a labelled case in a state machine. Local variables that need to survive across suspension points, like id and profile, are promoted to fields on a compiler generated class. When the function suspends, it stores its locals in that object, returns COROUTINE_SUSPENDED, and hands control back to the dispatcher. When the awaited work completes, something (a dispatcher, a network callback, a timer) calls continuation.resumeWith(result), which re enters the state machine at the label following the suspension.

This rewrite has a name: Continuation Passing Style, or CPS. Once you have seen the generated state machine, suspend stops feeling like magic and starts feeling like what it is: a carefully engineered code transformation that lets you write sequential code that actually runs as a sequence of callbacks.

The full chapter walks through a decompiled state machine line by line, including how local variables are promoted, how the fast path avoids scheduling entirely when a suspending function has nothing to wait on, and how cancellation hooks into the same machinery. If you want to read that walkthrough now, the companion guide Suspend function internals covers the same ground. For a broader conceptual overview of how coroutines fit together, Kotlin coroutines explained is the right starting point.

The four builders at a glance

A coroutine builder is a function that takes a suspending lambda, wraps it into a coroutine, and hands it to a dispatcher. You will reach for four of them every day.

val job = scope.launch {
    saveToDisk(payload)
}

launch is the workhorse. It starts a coroutine that runs independently, returns a Job, and is used for side effects: updating state, writing to a database, pushing an analytics event. It is the right choice for fire and forget work. If you want to be told when it finishes, call job.join(). If you want to stop it, call job.cancel().

val deferred = scope.async {
    fetchProfile(id)
}
val profile = deferred.await()

async is for concurrent computations whose results you actually need. It returns a Deferred<T>, which is a Job that also carries a value. You call await() on it to suspend until the result is ready. async is the right tool when you want two network calls to run in parallel and then combine their results.

fun main() = runBlocking {
    println("Hello from main")
}

runBlocking is the bridge between the blocking and suspending worlds. It starts a coroutine on the current thread and blocks that thread until the coroutine completes. Use it in main functions and in tests. Do not use it inside a coroutine you already have. That is what withContext exists for.

suspend fun loadBitmap(path: String): Bitmap =
    withContext(Dispatchers.IO) {
        BitmapFactory.decodeFile(path)
    }

withContext does not start a new coroutine. It shifts the current coroutine onto a different dispatcher, usually Dispatchers.IO or Dispatchers.Default, runs the block, and shifts back to the original dispatcher when the block returns. This is how you safely perform blocking I/O from a suspending function, and it is why most of your suspend functions will never need to spawn children at all.

This is the natural stopping point for the preview. Everything above gives you enough to read and write real coroutine code today. The rest of the chapter goes progressively deeper into the machinery that makes all of this work, and, more importantly, makes it fail predictably when things go wrong.

What the rest of the chapter covers

The full chapter continues from here and covers:

  • Coroutine Context and Job: how every coroutine carries a context (dispatcher, job, name, exception handler), how contexts are combined with the + operator, and the Job lifecycle from New through Active, Completing, Completed, Cancelling, and Cancelled.
  • Coroutine Scope and structured concurrency: why scopes are the single best idea in the coroutines library. Children cannot outlive parents, cancellation flows down, failures propagate up, and leaks become structurally hard to write.
  • Dispatchers and Channels: Dispatchers.Main, Dispatchers.IO, Dispatchers.Default, custom dispatchers, and how Channel lets coroutines hand values to each other without shared mutable state.
  • Cancellation and exception handling: cooperative cancellation, CancellationException, SupervisorJob, CoroutineExceptionHandler, and why catching Throwable inside a coroutine is almost always a bug.
  • Flow basics, operators, and transformations: flow { }, cold versus hot streams, map, filter, flatMapConcat, flatMapLatest, buffer, conflate, combine, zip, and when each is the right choice.
  • StateFlow and SharedFlow: the hot side of Flow, how they replace LiveData and BehaviorSubject, and the subtle gotchas around replay, buffering, and UI state holders.
  • Internal mechanisms: a walkthrough of the compiler transformation. What a Continuation looks like on the heap, what ContinuationImpl does, how the state machine stores locals, how intercepted() and DispatchedContinuation wire a dispatcher in, and how invokeSuspend ties it all together.

There are also two advanced quiz sections at the end, around twenty tricky questions each, that lock the concepts in. They are the same questions I use when interviewing senior Kotlin candidates, so they are calibrated to matter.

Get the full chapter

If the preview above was your speed, the rest of the chapter is written in the same tone and goes much deeper. Drop your email below and I will send you the full 70 page Coroutines chapter as a PDF, plus a short note about how the rest of the book is organized. No drip campaign, no upsell sequence, just the chapter and an occasional note when something genuinely new is published.

As always, happy coding!

Jaewoong (skydoves)

Get the free first chapter

Enter your email to receive a 30-page preview PDF and get notified about new chapters.

One email. No spam. Unsubscribe anytime.

Frequently asked questions

Is the chapter really free?

Yes. Read the opening pages here without any gate, then enter your email for the rest of the chapter as a PDF.

How long is the full chapter?

The full Coroutines chapter is around 70 pages in the final book layout. It covers everything from the first `launch { }` to the internals of Flow, StateFlow, and the CPS transformation.

Do I need to buy the book to read this preview?

No. The preview on this page is free forever with no sign-up. The email gate only unlocks the full chapter PDF.

What format is the PDF?

A clean, copy-pasteable PDF with the same typography and code styling as the printed book. Runs of code are selectable so you can paste them into IntelliJ or Android Studio.

Will you spam me?

No. You'll get the chapter, a short note about how the book is organized, and an occasional update when a new chapter or guide goes live. Unsubscribe in one click.

Who is this book for?

Kotlin developers who already write everyday Kotlin and want to deeply understand how the language works, especially coroutines, Flow, generics, and the compiler machinery behind them.

Is this suitable for Android developers?

Yes. The chapter uses JVM examples so everything transfers cleanly to Android, and later sections of the book cover Android-specific patterns like viewModelScope, lifecycle-aware flows, and UI state holders.