Coroutines Basics Explained
KOTLIN › Concurrency
A thread is an expensive resource: the operating system can only run so many of them at once, and one that's sitting idle waiting for a network response is a thread wasted. Coroutines exist to solve exactly that problem. A suspend function can pause its work and hand the thread back for other work to use, then pick up again later, possibly on a different thread. This is why interviewers push past the syntax and ask specifically whether you understand suspending versus blocking, because that difference is the entire point of coroutines.
The cleanest way to see it is delay next to Thread.sleep:
// Blocks the thread for the full second, nothing else can use it
launch { Thread.sleep(1000) }
// Suspends the coroutine; the thread is free during the wait
launch { delay(1000) }
Thread.sleep parks the thread itself, so if that thread is shared with other coroutines, they all stall too. delay is a suspend function: it pauses only the coroutine, releases the thread immediately, and schedules a resume once the time is up.
Because a suspend function needs to be able to pause partway through and resume later, the compiler can't compile it like an ordinary function. It applies a continuation-passing style transform: it adds a hidden Continuation parameter to the function and rewrites the body into a state machine, one step per suspension point. When execution hits a suspension point, the state machine records exactly where it stopped, and that record is what gets resumed later, on whatever thread the dispatcher picks.
suspend fun fetchUser(id: Int): User {
val data = networkCall(id) // suspension point, step 1
return parse(data) // step 2, resumes here
}
// Roughly compiles to something that carries a Continuation
// and a label marking which step to resume at
This machinery is also why a suspend function can only be called from two places: another suspend function, which already has a Continuation to pass along, or from inside a coroutine started by a builder like launch or async, which creates one. Calling delay from an ordinary function is a compile error, there's nowhere for the continuation to come from.
With suspending functions in place, you start coroutines with a **builder**. The two you'll reach for constantly are launch and async, and they differ in what you get back.
launch starts a coroutine for its side effects and returns a Job. There's no result to collect, it's fire-and-forget:
val job: Job = scope.launch {
saveToDatabase(user)
}
// job is returned immediately; saveToDatabase keeps running independently
async starts a coroutine that computes a value and returns a Deferred<T>. You call await() on it to suspend until that value is ready:
val deferred: Deferred<User> = scope.async { api.getUser(id) }
val user = deferred.await() // suspends, doesn't block, until the result lands
Both builders inherit the scope's dispatcher by default, and both let you override it per call: scope.launch(Dispatchers.IO) { ... } runs specifically on IO regardless of what the scope defaults to.
async earns its keep for parallel decomposition: start two things at once, then combine their results.
val a = scope.async { fetchScoreA() }
val b = scope.async { fetchScoreB() }
val total = a.await() + b.await() // both ran concurrently, not one after the other
If you don't need a return value, launch is the simpler, correct tool.
launch and async both assume you're already inside a coroutine, but something has to start first. runBlocking is that bridge: it's a builder that blocks the calling thread until its body finishes, letting ordinary blocking code call into the coroutine world.
fun main() = runBlocking {
val result = async { fetchData() }.await()
println(result)
}
That makes it the right fit for exactly two places: a main() function, and tests, where blocking the thread while coroutines run is expected and fine. It's the wrong tool anywhere on Android's UI path. Calling runBlocking on the main thread freezes the UI for as long as the body takes, which defeats the entire reason you reached for coroutines in the first place.
There's a related builder worth knowing by name: coroutineScope { }. Unlike runBlocking, it's itself a suspend function, so it doesn't block a thread and doesn't switch context, it runs on the same dispatcher that called it, and suspends the calling coroutine until every child launched inside it finishes. It's how you group a batch of child coroutines and wait for all of them without leaving your own scope.
Every coroutine you launch needs a CoroutineScope, and on Android you'll almost always use one handed to you, viewModelScope, lifecycleScope, rather than building your own. A scope bundles a CoroutineContext, dispatcher, Job, and more, and ties coroutines to a lifetime.
That lifetime relationship is what **structured concurrency** means: a coroutine launched inside a scope becomes that scope's child. Cancel the scope, or its Job, and the cancellation propagates down to every child automatically. You never hunt down and cancel each one individually.
val scope = CoroutineScope(Job())
scope.launch { delay(5000); println("A") }
scope.launch { delay(5000); println("B") }
scope.cancel() // both children are cancelled immediately
This is also what stops coroutines from leaking: no orphaned background work outliving the screen, or the object, that started it.
Cancellation propagating to children sounds automatic, but it's actually cooperative: cancelling a Job just flips a flag and, for a coroutine sitting in a suspending call like delay, throws a CancellationException at the next suspension point. A coroutine that never suspends, a tight CPU loop, never hits that check and keeps running regardless of the flag.
// BAD: ignores cancellation entirely, loops forever
launch {
while (true) { heavyComputation() }
}
// GOOD: checks the cancellation flag on every iteration
launch {
while (isActive) {
heavyComputation()
}
}
isActive reads the flag without throwing; ensureActive() throws immediately if the coroutine has been cancelled, which is handy inside a loop body where you want to bail out with a single call. Any long-running loop or block that doesn't otherwise suspend needs one of these, or cancellation is just a polite request nobody's listening to.
launch and async also differ in how they hand you a failure. An uncaught exception inside launch propagates to its scope immediately, the moment it happens, because nobody is going to call anything on a Job to retrieve it.
scope.launch {
throw RuntimeException("boom") // scope fails right away
}
async behaves differently: it stores the exception inside the Deferred instead of propagating it right away, and only rethrows it when you call await().
val d = scope.async {
throw RuntimeException("boom") // held here silently
}
// ... nothing happens yet ...
d.await() // RuntimeException thrown here
If you start an async and never call await() on it, that exception can sit unnoticed. It's one more reason to only reach for async when you actually intend to await the result.
That immediate-propagation behavior has a sharper edge inside coroutineScope { }: if one child throws, the scope cancels every other child too, because structured concurrency treats a child failure as a failure of the whole scope.
coroutineScope {
launch { throw Exception("fail") } // this fails
launch { delay(1000); println("never runs") } // cancelled as a result
}
supervisorScope { } changes that rule: it uses a SupervisorJob, so one child's failure is isolated and doesn't cancel its siblings.
supervisorScope {
launch { throw Exception("fail") } // isolated
launch { delay(1000); println("still runs") } // keeps going
}
Reach for supervisorScope when your children are independent, for example several unrelated network calls, and one failing shouldn't take down the rest.
A CoroutineDispatcher decides which thread, or thread pool, actually runs a coroutine's code. Picking the right one is a constant interview probe, and the answer depends entirely on what kind of work you're doing.
- **Dispatchers.Main**: UI updates and anything touching views. Must stay fast, nothing blocking runs here. - **Dispatchers.IO**: blocking I/O, network calls, disk reads, database queries. Backed by a large, elastic pool built for lots of concurrently-blocked calls. - **Dispatchers.Default**: CPU-bound work, sorting, parsing, JSON processing. Its pool is sized to your device's CPU core count, so it doesn't help to throw more concurrent work at it than that.
Matching the dispatcher to the work matters: running blocking I/O on Default starves a pool sized for computation, and running CPU-heavy work on IO wastes a pool built for waiting, not computing.
Two nuances worth knowing beyond the basic three.
First, IO and Default actually share the same underlying thread pool in modern kotlinx.coroutines. IO just allows far more concurrent blocking tasks against it than Default does, since blocked threads waiting on I/O don't need to be limited to the CPU core count. You can cap how many run at once for a subsystem with limitedParallelism:
val dbDispatcher = Dispatchers.IO.limitedParallelism(4)
withContext(dbDispatcher) { readFromDatabase() }
Second, Dispatchers.Unconfined is the odd one out. It starts the coroutine on the caller's thread, but only until the first suspension point, after that, it resumes on whatever thread the suspending call happens to resume on, with no guarantee it's the same one.
launch(Dispatchers.Unconfined) {
println(Thread.currentThread().name) // caller's thread
delay(100) // first suspension point
println(Thread.currentThread().name) // wherever delay resumed, likely different
}
It's an advanced tool for specific cases, like tests, not something you reach for in everyday application code.
withContext is how you switch dispatchers for a block of code and hand the result back, and it's the tool that makes a suspend function main-safe. It suspends, runs its block on the given context, returns the result, and switches back to whatever context called it.
suspend fun readFile(file: File): String =
withContext(Dispatchers.IO) {
file.readText() // blocking I/O, safely off the main thread
} // resumes on the ORIGINAL dispatcher
The caller doesn't need to know or care which dispatcher did the work: call readFile from Dispatchers.Main, and you get the string back on Dispatchers.Main, with no blocking anywhere along the way. That's the whole idea of main-safety, baked into the function itself rather than left for every call site to remember.
The same shape works for any dispatcher, not just IO:
suspend fun <T> runOn(dispatcher: CoroutineDispatcher, block: suspend () -> T): T =
withContext(dispatcher) { block() }
Put it together and the whole system reads as one idea repeated at different scales. A suspend function pauses instead of blocking, because the compiler gives it a way to remember where it stopped. launch starts work you don't need a result from; async starts work you do, and you await() it, with their failures behaving differently too, launch fails its scope immediately, async holds the failure until you await it. Every coroutine lives inside a scope that owns its lifetime, cancels its children together, and only stops a child that's actually cooperating by checking for cancellation. withContext moves a block of work onto the right dispatcher and moves you back, which is how a suspend function stays safe to call from the main thread without its caller having to think about threading at all.
The line interviewers are listening for: coroutines replace blocked threads with cheap, pausable units of work, and every primitive here, builders, scopes, dispatchers, withContext, exists to make that pausing safe, structured, and running on the thread it should be running on.