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.
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:
Dispatchers.Main— the UI thread, on platforms that have one.Dispatchers.IO— a pool sized for blocking I/O work like network or disk calls.Dispatchers.Default— a pool sized to the number of CPU cores, for CPU-heavy work like sorting or parsing.
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
-
Launching without structure. Reaching for
GlobalScope.launchinstead of a scope tied to your component's lifecycle means the coroutine keeps running even after nothing needs its result. -
Sequential awaits that should be concurrent.
Calling
await()immediately after eachasynccall, instead of starting both first, silently turns concurrent work back into sequential work. -
Blocking calls inside a coroutine. A suspend
function that calls a blocking API directly (unbuffered file I/O,
Thread.sleep) blocks the underlying thread just like it would anywhere else — suspension only helps at points where the code actually suspends.
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.