Coroutines: Asynchronous Code That Reads Like It Isn't

An introduction to Kotlin coroutines — suspend functions and the state machine behind them, launch and async, structured concurrency with measured concurrency, dispatchers, and cooperative cancellation.

Asynchronous code on the JVM has always forced a choice between two bad options: block a thread and waste it waiting, or go non-blocking and shatter your logic into callbacks that no longer read top to bottom. Coroutines are Kotlin’s way out. You write code that looks ordinary and sequential, and the runtime quietly frees the thread whenever you’re waiting. This is the feature that pulled many teams to Kotlin, and it’s the right note to end on.

This is the last chapter, following operator overloading. It’s an introduction — coroutines are deep enough for a series of their own — but you’ll leave knowing what they are and why they matter. The examples were run against Kotlin 2.4.10 with kotlinx-coroutines 1.10.2, and the timing numbers below are real measurements.

The problem, concretely

Fetch a user, then their orders. Blocking, it’s simple but ties up a thread for the whole network round trip:

val user = fetchUser(id)         // thread blocked, waiting
val orders = fetchOrders(user)   // blocked again

Made non-blocking with callbacks it stops blocking but stops reading like a sequence: the logic inverts into nested handlers. Coroutines give you the first version’s readability with the second version’s efficiency.

suspend functions

The keyword is suspend. A suspend function can pause at certain points — while waiting on the network, say — hand its thread back to do other work, and resume where it left off when the result arrives:

suspend fun loadOrders(id: Int): List<Order> {
    val user = fetchUser(id)         // may suspend here
    return fetchOrders(user)         // and here
}

This reads exactly like the blocking version. The difference is invisible in the source: at each call to another suspend function — a suspension point — the coroutine might release the thread instead of blocking it. There are no callbacks and no inverted control flow because the compiler does the inversion for you: it rewrites the function into a state machine, where each suspension point is a state the function can pause at and resume from. That rewrite is why suspend is a compiler feature (the pausing) while launch and async are a library (the scheduling). It’s also why a suspend function can only be called from another suspend function or from inside a coroutine — the pausing has to happen somewhere that knows how to handle it.

Launching coroutines

launch starts a coroutine that runs alongside the rest of your code without returning a result, fire and forget:

scope.launch {
    val orders = loadOrders(42)
    display(orders)
}

When you need a result, async returns a Deferred<T>, and await() suspends until it’s ready. This is how you run independent work concurrently and combine it — and the payoff is measurable. Two 100ms fetches, run sequentially, take about as long as you’d fear; run with async, they overlap:

coroutineScope {
    val user = async { fetchUser(id) }       // both start now
    val settings = async { fetchSettings(id) }
    render(user.await(), settings.await())   // ~113ms total, not ~211ms
}

Measured, the sequential version took ~211ms (two 100ms waits back to back) and the concurrent version ~113ms (the waits overlapped) — the code still reads like a list of steps.

Structured concurrency

The detail that makes coroutines trustworthy is structured concurrency: coroutines live inside a scope, and the scope doesn’t finish until every coroutine launched in it finishes. That coroutineScope { } block won’t return until both async jobs complete — and if one fails, its siblings are cancelled and the error propagates out of the scope, rather than a forgotten background task leaking somewhere. Verified: launch two children, throw from one, and the exception surfaces at the coroutineScope boundary while the scope tears the other down:

try {
    coroutineScope {
        launch { delay(50); println("sibling") }
        launch { delay(10); throw IllegalStateException("boom") }
    }
} catch (e: IllegalStateException) {
    println(e.message)      // "boom" — propagated, siblings cancelled
}

No orphaned threads, no fire-and-forget work that outlives the thing that started it. This is the property callback-based async can’t give you, and it’s the reason to prefer coroutineScope over launching into a long-lived scope by default.

Dispatchers: where the work runs

A dispatcher decides which thread (or pool) a coroutine runs on. Dispatchers.Default is a small pool sized to the CPU count, for CPU-bound work; Dispatchers.IO is a larger, elastic pool for blocking I/O, where threads mostly wait; Dispatchers.Main is the single UI thread on Android and the like. You switch with withContext:

val bytes = withContext(Dispatchers.IO) { file.readBytes() }   // hop to the IO pool, then back

The important idea: suspension lets many coroutines share few threads, because a suspended coroutine isn’t holding one. A thousand coroutines waiting on I/O don’t need a thousand threads — that’s the whole efficiency argument, and dispatchers are how you place the work without giving up on it.

Cancellation is cooperative

Cancelling a coroutine doesn’t forcibly kill it; it sets a flag that the coroutine observes at its suspension points. The library’s suspend functions (delay, withContext, and the rest) check for cancellation and throw CancellationException to unwind cleanly. The consequence to remember: a coroutine stuck in a tight, non-suspending loop won’t notice it’s been cancelled — cooperative cancellation needs a suspension point (or an explicit ensureActive()/isActive check) to cooperate at. And because CancellationException is how cancellation propagates, a blanket catch (e: Exception) that swallows it will break cancellation — one more reason runCatching is risky inside coroutines.

Final thoughts

Coroutines collapse the old trade-off: sequential-looking code that doesn’t hog threads, concurrency that can’t silently leak. suspend marks the functions that might pause and compiles them to a resumable state machine; builders like launch and async start them; structured concurrency ties their lifetimes to a scope you can reason about; dispatchers place the work; and cancellation cooperates at suspension points. There’s much more — Flow for asynchronous streams, channels, SupervisorJob, exception handlers — but the core is this small, and the measured overlap is why so many JVM teams made the switch.

That closes the series. We started with a type system that has no primitives and ended with concurrency that reads like it’s synchronous — and the throughline, chapter after chapter, has been the same: Kotlin takes a place where Java made you do extra work or accept a hidden risk, and quietly removes it. You now have the whole everyday language. The rest is practice.

Practice: reinforce this with the companion workbook — short, click-to-reveal problems.

Comments