What you'll learn
A coroutine always runs in a context — a bundle of information that includes which thread it uses and which scope owns it. Master context, dispatchers, and scopes and you control exactly where your async work runs and how its lifecycle is managed.
By the end you'll be able to:
- Understand
CoroutineContextandCoroutineScope - Pick the right dispatcher for CPU, I/O, and UI work
- Switch threads cleanly with
withContext - Compose concurrent work with
coroutineScopeandsupervisorScope
Context & scope
A CoroutineContext is a set of elements — chiefly a Job (the lifecycle handle) and a dispatcher (the thread policy). A CoroutineScope is simply something that holds a context and defines a boundary in which coroutines live. Every coroutine you launch is a child of a scope, and that parent-child link is what makes coroutines manageable rather than chaotic.
Key idea
Dispatchers
A dispatcher decides which thread (or pool) a coroutine runs on. Passing one to a builder overrides the inherited context:
import kotlinx.coroutines.*
fun main() = runBlocking {
launch(Dispatchers.Default) { // CPU-bound work
println("Default: ${Thread.currentThread().name}")
}
launch(Dispatchers.IO) { // network / disk I/O
println("IO: ${Thread.currentThread().name}")
}
launch { // no dispatcher: inherits the caller's
println("Inherit: ${Thread.currentThread().name}")
}
}| Dispatcher | Backed by | Use it for |
|---|---|---|
Dispatchers.Default | shared CPU pool | CPU-intensive work: parsing, sorting, math |
Dispatchers.IO | large elastic pool | blocking I/O: network, files, databases |
Dispatchers.Main | the UI thread | updating UI (Android/Compose); needs a UI library |
Dispatchers.Unconfined | the current thread | rare / advanced — usually avoid |
Switching with withContext
withContext runs a block on a different dispatcher and suspends until it returns — no callback, no manual thread juggling. It's the idiomatic way to move a blocking call off the main thread and bring the result back:
import kotlinx.coroutines.*
// Do the blocking read on IO, then hand the result back to the caller.
suspend fun loadData(): String = withContext(Dispatchers.IO) {
delay(200L) // pretend: a slow disk read
"data from disk"
}
fun main() = runBlocking {
println("start on ${Thread.currentThread().name}")
val data = loadData() // switches to IO and back automatically
println("got: $data")
}Tip
withContext returns a value; launch/async start new coroutines. Reach for withContext(Dispatchers.IO) around a blocking call, not a fresh launch — it's simpler and keeps the result in-line.coroutineScope & supervisorScope
coroutineScope creates a scope that waits for all its children and, if any child fails, cancels the siblings and rethrows. It's the building block for "do these things concurrently, then combine them":
import kotlinx.coroutines.*
suspend fun loadUser(): String { delay(100L); return "Ada" }
suspend fun loadFeed(): String { delay(100L); return "3 posts" }
// coroutineScope waits for ALL children; if one fails, it cancels the rest.
suspend fun loadDashboard(): String = coroutineScope {
val user = async { loadUser() }
val feed = async { loadFeed() }
"${user.await()} — ${feed.await()}"
}
fun main() = runBlocking {
println(loadDashboard())
}supervisorScope is the same but children fail independently — one crashing sibling doesn't take the others down. Use it when tasks are unrelated:
import kotlinx.coroutines.*
fun main() = runBlocking {
supervisorScope {
val a = launch {
throw RuntimeException("A failed") // does NOT cancel b
}
val b = launch {
delay(100L)
println("B finished fine")
}
}
}Recap & quick check
Key takeaways
- A CoroutineContext bundles a Job (lifecycle) and a dispatcher (thread policy); a CoroutineScope owns a context.
- Structured concurrency: a coroutine never outlives its scope — cancel the scope and children cancel too.
- Dispatchers.Default is for CPU work, Dispatchers.IO for blocking I/O, Dispatchers.Main for UI.
- withContext switches dispatcher for a block and returns its result — the clean way off the main thread.
- coroutineScope cancels siblings when one fails; supervisorScope lets children fail independently.
Quick check
1. Which dispatcher is best for a network request?
2. What does withContext(Dispatchers.IO) { ... } do?
3. In coroutineScope, what happens if one child coroutine throws?
4. What is structured concurrency?
5. When would you choose supervisorScope over coroutineScope?
You now steer where coroutines run. Next we'll go deeper into their lifecycles — structured concurrency, cancellation, and error handling — the skills that keep async code correct under pressure.