← back

A guide to Kotlin coroutines

Coroutines are Kotlin's answer to a problem every JVM developer runs into eventually: how do you write code that waits on things — network calls, database queries, timers — without either blocking a thread or descending into callback hell? The short version is that a coroutine is a unit of work that can suspend itself and resume later, without blocking the thread it's running on. This guide walks through the core pieces you need to use them well.

The problem they solve

A regular function call runs to completion once it starts. If it needs to wait — for a network response, say — the thread running it just sits there blocked, doing nothing else, until the wait is over. Threads are expensive: each one reserves its own stack, and a typical JVM can only comfortably run a few thousand at once. If your server needs to handle ten thousand concurrent requests that each spend most of their time waiting on I/O, one thread per request doesn't scale.

Coroutines solve this by letting a piece of code suspend — pause itself and give the thread back — and resume later, possibly on a different thread, without the caller needing to manage any of that explicitly. The code still reads top to bottom, like ordinary sequential code, even though the underlying execution is not sequential at all.

Suspend functions

The suspend keyword marks a function that can pause and resume. It doesn't make the function asynchronous by itself — it just means the function is allowed to call other suspend functions and give up the thread while waiting.

suspend fun fetchUser(id: String): User {
    val response = httpClient.get("/users/$id")
    return response.body()
}

You can only call a suspend function from another suspend function, or from a coroutine. That restriction is deliberate: it means the compiler always knows where suspension can happen, and you can never accidentally call a blocking-looking function from ordinary code without realizing it might suspend.

Starting a coroutine

Suspend functions need a coroutine to run in. The two most common ways to start one are launch and async, both extension functions on CoroutineScope.

fun refreshDashboard(scope: CoroutineScope) {
    scope.launch {
        val user = fetchUser(currentUserId)
        updateUi(user)
    }
}

launch starts a coroutine and returns a Job you can use to cancel or wait on it, but it doesn't return a value. Use it for work you're firing off for its side effects.

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val user = async { fetchUser(currentUserId) }
    val notifications = async { fetchNotifications(currentUserId) }
    Dashboard(user.await(), notifications.await())
}

async returns a Deferred<T>, which you call .await() on to get the result. Because both async calls above start before either one is awaited, the two requests run concurrently rather than one after the other.

Structured concurrency

This is the idea that makes coroutines safe to use at scale: every coroutine runs inside a CoroutineScope, and a scope can't complete until all the coroutines launched inside it have finished. If a child coroutine throws, the failure propagates up to its scope, and the scope cancels its other children. Work never gets silently orphaned.

coroutineScope { }, used in the example above, creates a new scope tied to the surrounding suspend function — it won't return until everything launched inside it is done. This is the pattern to reach for whenever you need to run several suspend calls concurrently and combine their results.

Rule of thumb: a coroutine should never outlive the scope that has logical ownership of it. If you find yourself passing GlobalScope around to dodge this, that's usually a sign the structure needs rethinking, not a shortcut to take.

Dispatchers

A dispatcher decides which thread or thread pool a coroutine runs on. The three you'll use most often:

Switching dispatchers mid-coroutine is cheap and explicit with withContext:

suspend fun loadAndParse(path: String): Report = withContext(Dispatchers.IO) {
    val raw = readFile(path)
    withContext(Dispatchers.Default) {
        parseReport(raw)
    }
}

Cancellation and exceptions

Cancellation in coroutines is cooperative: calling job.cancel() doesn't forcibly kill anything, it sets a flag that suspend functions check at their suspension points. Well-behaved suspend functions — anything from kotlinx.coroutines, and any suspend function you write that calls them — check this automatically and throw a CancellationException when it's set.

By default, an unhandled exception in a coroutine cancels its parent scope and everything else running inside it. If you want one child's failure to not take down its siblings, use a SupervisorJob:

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

scope.launch {
    // if this fails, it doesn't cancel the sibling below
    riskyOperation()
}
scope.launch {
    steadyBackgroundWork()
}

To handle the exception itself rather than just contain it, attach a CoroutineExceptionHandler to the scope.

Flow, briefly

Everything so far deals with a single suspended value. When you need a stream of values over time — sensor readings, database updates, search-as-you-type results — Flow is the coroutine-native equivalent of a lazy, cold sequence.

fun observeSearchResults(query: Flow<String>): Flow<List<Result>> =
    query
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { term -> searchApi.search(term) }

A flow does nothing until it's collected, and each collector gets its own independent run of the upstream logic. That laziness is what makes operators like debounce and flatMapLatest useful for exactly the kind of UI-driven, event-based streams shown above.

Common pitfalls

Where to go from here

The mental model that ties all of this together: a coroutine is cheap, structured concurrency keeps ownership of that coroutine clear, dispatchers decide where it runs, and cancellation flows through the same structure that started it. Once that clicks, most of the coroutine API reads as a fairly small set of tools built consistently on top of those ideas.

← Back to work