If you've written Kotlin for any length of time, you've bumped into coroutines. Maybe you copy-pasted viewModelScope.launch { } into an Android app and moved on with your life. That's fine coroutines are designed to let you do that. But understanding what's actually happening under the hood will save you from some genuinely confusing bugs down the road (leaked jobs, silently swallowed exceptions, "why is my app still running after I closed the screen").
This article walks through coroutines from the ground up: what suspend actually means, how coroutines differ from threads, and how structured concurrency keeps your async code from turning into spaghetti.
The problem coroutines solve
Before coroutines, Kotlin (like Java) had two realistic options for async work:
- Blocking threads - simple to reason about, but threads are expensive. Spin up a few thousand and your app grinds to a halt.
- Callbacks - cheap, but they nest. Chain a few async calls together and you get callback hell, plus manual, error-prone cancellation and error handling.
Coroutines give you a third option: code that reads like simple, sequential, blocking code, but doesn't actually block a thread while it waits.
suspend fun fetchUser(id: String): User {
val profile = api.getProfile(id) // "waits" here without blocking a thread
val posts = api.getPosts(id) // then continues
return User(profile, posts)
}
No callbacks, no .then() chains. Just sequential code that happens to be asynchronous.
What suspend actually means
The suspend keyword marks a function that can pause its execution and resume later, without blocking the underlying thread. That's it that's the whole idea. The magic is in how.
When you write a suspend fun, the Kotlin compiler transforms it using a technique called Continuation-Passing Style (CPS). Roughly speaking, every suspend function gets an extra hidden parameter a Continuation that represents "what to do next." Under the hood, fetchUser above is compiled into something conceptually like:
fun fetchUser(id: String, continuation: Continuation<User>): Any?
When the function hits a suspension point (like api.getProfile(id)), it doesn't block the thread. Instead, it:
- Saves its local state (which step it's on, local variables) into the continuation.
- Returns control of the thread back to whoever called it, so that thread can go do other useful work.
- When the underlying operation completes, the continuation is invoked to resume execution exactly where it left off.
This is why coroutines are often called "lightweight threads." You can launch hundreds of thousands of them without breaking a sweat, because a suspended coroutine doesn't hold a thread hostage it just holds a small object (the continuation) on the heap.
A key rule: suspend functions can only be called from other suspend functions, or from a coroutine. The compiler enforces this, because the whole CPS transformation only makes sense within that context.
Coroutines vs. threads
It's tempting to think of a coroutine as "a thread, but cheaper." That's directionally true but hides an important distinction:
| Threads | Coroutines | |
|---|---|---|
| Managed by | OS | Kotlin runtime |
| Cost to create | Expensive (MBs of stack, context switches) | Cheap (a small object) |
| Blocking behavior | Blocks the OS thread while waiting | Suspends without blocking the thread |
| Concurrency model | Preemptive | Cooperative |
Multiple coroutines can run on a single thread, taking turns whenever one of them suspends. You can also have coroutines dispatched across a pool of threads. Which brings us to dispatchers.
Launching coroutines: builders and dispatchers
You don't call suspend functions directly from regular code you need a coroutine builder to start a coroutine. The three you'll use constantly:
// Fire-and-forget, returns a Job
val job = scope.launch {
doSomething()
}
// Returns a value asynchronously, via a Deferred
val deferred = scope.async {
computeValue()
}
val result = deferred.await()
// Bridges blocking code into the coroutine world blocks the current thread
runBlocking {
doSomething()
}
runBlocking is mostly for main() functions, tests, or bridging legacy blocking code. In application code Android, backend services you'll almost always use launch or async inside an existing scope.
Every coroutine builder takes an optional CoroutineDispatcher, which decides which thread (or thread pool) the coroutine runs on:
-
Dispatchers.Main- the UI thread (Android/Swing/etc.). Use for UI updates. -
Dispatchers.IO- a thread pool tuned for blocking I/O (network calls, file access, database queries). -
Dispatchers.Default- a thread pool sized to your CPU cores, for CPU-intensive work (sorting, parsing, computation). -
Dispatchers.Unconfined- runs in the caller's thread until the first suspension point, then resumes wherever the suspending call happens to complete. Rarely what you want in production code.
viewModelScope.launch(Dispatchers.IO) {
val data = repository.fetchLargeDataset() // runs on the IO pool
withContext(Dispatchers.Main) {
updateUi(data) // hop back to Main to touch UI
}
}
withContext is the idiomatic way to switch dispatchers within a coroutine, and it suspends the calling coroutine until the block finishes no manual thread juggling required.
Structured concurrency: the real headline feature
Here's the part that actually changes how you write software, not just how it's syntactically expressed.
Structured concurrency means every coroutine runs inside a CoroutineScope, and that scope defines the coroutine's lifetime. A coroutine can't outlive its scope, and a scope won't complete until all the coroutines launched inside it are done. Parent-child relationships are explicit and enforced by the runtime, not by convention.
Why does this matter? Consider the alternative unstructured concurrency, where you just fire off a coroutine into the void:
// DON'T do this GlobalScope has no defined lifetime
GlobalScope.launch {
fetchAndUpdateUi()
}
If the screen that started this is destroyed before it finishes, nothing cancels it. It keeps running, potentially crashing when it tries to touch a dead UI, or just wasting resources. You've created a leak.
With structured concurrency, you tie the coroutine to something with a well-defined lifecycle:
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
fetchAndUpdateUi()
}
}
}
When the ViewModel is cleared, viewModelScope is automatically cancelled, and every coroutine launched in it and every child coroutine those launch is cancelled too, recursively. You get automatic cleanup for free, just by nesting your work correctly.
Building your own scopes
You're not limited to framework-provided scopes like viewModelScope or lifecycleScope. You can create your own:
class UserRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun refresh() {
scope.launch {
syncUsers()
}
}
fun close() {
scope.cancel() // cancels everything launched in this scope
}
}
A CoroutineScope is really just a CoroutineContext a combination of a Job (governs lifecycle/cancellation) and usually a CoroutineDispatcher (governs threading).
coroutineScope vs. supervisorScope
Inside a suspend function, you can create a scoped block that waits for its children before returning:
suspend fun loadDashboard() = coroutineScope {
val user = async { fetchUser() }
val stats = async { fetchStats() }
Dashboard(user.await(), stats.await())
}
With plain coroutineScope, if any child fails, the whole scope fails siblings are cancelled and the exception propagates up. That's usually what you want: if fetchUser() throws, there's no point continuing to wait for fetchStats().
Sometimes, though, you want independent failure domains one child failing shouldn't cancel the others. That's supervisorScope:
suspend fun loadOptionalWidgets() = supervisorScope {
launch { loadWeatherWidget() } // if this fails...
launch { loadNewsWidget() } // ...this keeps running
}
SupervisorJob (used above in the repository example) does the same thing at the scope level useful when a scope manages several independent, long-running tasks that shouldn't take each other down.
Cancellation is cooperative
Cancelling a coroutine doesn't forcibly kill it it sets a flag and relies on the coroutine checking that flag. All suspending functions from kotlinx.coroutines (delay, withContext, channel operations, etc.) check for cancellation automatically and throw a CancellationException at their next suspension point.
This means tight, non-suspending loops need to check in manually:
suspend fun processLargeList(items: List<Item>) {
for (item in items) {
ensureActive() // throws if the coroutine was cancelled
process(item)
}
}
If you catch exceptions broadly in a coroutine, be careful not to accidentally swallow CancellationException that breaks cancellation propagation and is a classic source of "why won't this coroutine stop" bugs:
try {
doWork()
} catch (e: Exception) {
// Bad: this also catches CancellationException
log(e)
}
Prefer catching specific exceptions, or re-throw CancellationException if you catch Exception broadly.
Exception handling
Exceptions in coroutines propagate up through the parent-child hierarchy, which is another consequence of structured concurrency. An unhandled exception in a child cancels its parent and siblings (unless you're using a SupervisorJob).
For coroutines you don't await() i.e., ones launched with launch, where you're not directly collecting a result you can install a CoroutineExceptionHandler as a last line of defense:
val handler = CoroutineExceptionHandler { _, exception ->
log.error("Unhandled coroutine exception", exception)
}
scope.launch(handler) {
riskyOperation()
}
Note this handler only fires for uncaught exceptions in launch-style coroutines at the top of a hierarchy it won't help with async, where exceptions are stored in the Deferred and only surface when you call .await().
Flows: coroutines for streams of values
suspend functions return a single value. When you need a stream of values over time think a sequence of location updates or a live search-as-you-type Flow is the coroutine-native answer:
fun observeLocation(): Flow<Location> = flow {
while (true) {
emit(getCurrentLocation())
delay(1000)
}
}
viewModelScope.launch {
observeLocation()
.filter { it.accuracy < 50 }
.collect { location ->
updateMap(location)
}
}
Flows are cold by default (nothing runs until you collect), fully cancellable, and integrate with the same structured concurrency and dispatcher machinery as everything else so cancelling the collecting scope cancels the flow's producer too.
Putting it together
A few practical takeaways worth keeping on hand:
-
Never use
GlobalScopein production code always tie coroutines to a scope with a defined lifecycle. -
Use
Dispatchers.IOfor blocking calls,Dispatchers.Defaultfor CPU-bound work, and reserveDispatchers.Mainfor touching the UI. -
Prefer
coroutineScopeoverasync+manual join logic when you want fail-fast behavior across concurrent children. -
Reach for
SupervisorJob/supervisorScopewhen children should fail independently. -
Don't swallow
CancellationExceptionin broad catch blocks. -
Use
Flowinstead of manually managed callbacks or channels when you're modeling a stream rather than a single async result.
Coroutines look simple on the surface sequential-looking code that's secretly async but that simplicity is backed by a genuinely well-thought-out concurrency model. Once structured concurrency clicks, you stop thinking about "starting background work" and start thinking about "whose lifetime does this belong to," which turns out to be the question that actually prevents bugs.
Top comments (0)