Coroutines: Asynchronous Code That Reads Like It Isn't


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 the series on.

This is the last post, following operator overloading. It’s an introduction, not the whole story — coroutines are deep enough for a series of their own — but you’ll leave knowing what they are and why they matter.

The problem, concretely

Fetch a user, then fetch their orders. Blocking, it’s simple but it 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 also 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 — and hand its thread back to do other work, then 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. No callbacks, no inverted control flow. The compiler rewrites the function into a state machine that can pause and resume; you just write the straight-line code.

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. That somewhere is a coroutine builder.

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 back, async returns a Deferred<T>, and await() suspends until it’s ready. This is how you run independent work concurrently and then combine it:

coroutineScope {
    val user = async { fetchUser(id) }
    val settings = async { fetchSettings(id) }
    render(user.await(), settings.await())   // both ran in parallel
}

The two fetches overlap instead of running one after the other, and 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, the others are cancelled and the error propagates out, instead of a forgotten background task leaking somewhere. No orphaned threads, no fire-and-forget work that outlives the thing that started it.

Where the work runs is controlled by a dispatcherDispatchers.IO for blocking I/O, Dispatchers.Default for CPU work — but that’s a dial you turn later. The model above is the part to absorb first.

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, builders like launch and async start them, and structured concurrency keeps their lifetimes tied to a scope you can reason about. There’s much more — flows, channels, cancellation, exception handling — but the core idea is this small, and it’s why so many JVM teams made the switch.

That closes the series. We started with the type system that has no primitives and ended with concurrency that reads like it’s synchronous — and the throughline, post after post, 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.