In the previous topic, we used CoroutineScope to control how child coroutines behave, and saw how passing SupervisorJob() to the scope builder changes error propagation. But SupervisorJob is just one piece of a bigger picture: every coroutine carries around a CoroutineContext that also decides which thread it runs on, how uncaught exceptions get reported, and more. Understanding this context is what lets you fix real problems — like a UI freezing during a network call, or an exception silently disappearing — instead of guessing why your coroutines behave the way they do. Let's look at what's actually inside a coroutine's context and how to control it.
Context
The CoroutineScope interface has only one property — coroutineContext — which holds all the information a coroutine needs to run. Think of it like a Map: values are stored there together with their keys.
If we look at a supervised scope from before, we can see what's inside its context:
import kotlinx.coroutines.*
fun main() {
val supervisedScope = CoroutineScope(SupervisorJob())
println(supervisedScope)
}Although CoroutineScope(...) looks like we're creating an instance of the CoroutineScope interface directly, it's actually a builder function — Kotlin doesn't let you instantiate an interface directly, so under the hood this wraps the given context in a real CoroutineScope implementation for us.
Running it prints something like:
CoroutineScope(coroutineContext=SupervisorJobImpl{Active}@4534b60d)This is just the default toString() of the context. SupervisorJobImpl is the concrete class backing our SupervisorJob(), {Active} is its current state, and @4534b60d is the object's hash code — Kotlin includes it so that two different job instances don't print identically in logs. Right now the context has only one element, the job, which we could also fetch directly with supervisedScope.coroutineContext[Job].
We can add more elements — for example, an exception handler:
import kotlinx.coroutines.*
val handler = CoroutineExceptionHandler { _, exception ->
println("Got ${exception.message}")
}
fun main() {
val supervisedScope = CoroutineScope(SupervisorJob() + handler)
println(supervisedScope)
}This prints something like:
CoroutineScope(coroutineContext=[SupervisorJobImpl{Active}@7e32c033, MainKt$special$$inlined$CoroutineExceptionHandler$1@7ab2bfe1])Now there are two elements listed: the job, and our handler. So what is the handler, and what else can go in the context?
Tip: if you want a refresher on how key-value storage works in Kotlin, see the Map topic — CoroutineContext follows the same basic idea, just with a fixed set of well-known element types instead of arbitrary keys.
Jobs, ExceptionHandlers, Dispatchers
The most important elements we can add to the context are Job, ExceptionHandler, and Dispatcher (this list isn't exhaustive).
Job represents the work itself, including the hierarchy of children jobs. It has a state (Active/Completed/Cancelled) and defines how a failure or cancellation propagates to parent and children jobs.
ExceptionHandler handles uncaught exceptions in a coroutine or its children. It's useful for logging, but it doesn't give you a way to catch an exception and recover from it — that's what
try/catchis for. It also only has an effect when installed on the root coroutine, since children automatically propagate uncaught errors up to the root.Dispatcher defines which thread or threads run the coroutine's code.
A coroutine can only hold one element of each type in its context. If you add more than one of the same kind — say, two exception handlers — the last one silently overrides the earlier ones. For example, CoroutineScope(SupervisorJob() + handler1 + handler2) will only ever use handler2.
If we don't provide an element of a given type, a default one is used instead: there's a default error handler and a default dispatcher for each platform. Jobs are the exception — there's no single default job, since every coroutine needs its own to track its individual lifecycle, so a new one is always created if we don't supply one.
More about Dispatchers
Dispatcher is just an interface, so we can implement our own if needed. Most platforms also provide their own specialized dispatchers, in addition to the three standard ones available everywhere:
Dispatchers.Default— used automatically if no dispatcher is specified. Schedules coroutine execution on a background thread from a shared thread pool. Good for CPU-bound computation.Dispatchers.IO— uses a separate shared thread pool designed for I/O work that spends time waiting on data. Use it for disk reads/writes and network requests.Dispatchers.Unconfined— starts the coroutine on the current thread and runs until the first suspension point, then resumes on whatever thread is convenient. It's rarely the right choice; avoid it unless you have a specific reason to use it.
If we need our own pool of one or more threads, newSingleThreadContext() and newFixedThreadPoolContext() create a thread pool and a dispatcher that uses it. These are handy for a fixed number of long-running background jobs.
If instead we just need to cap how many coroutines run in parallel, limitedParallelism() is usually the better option. It can be applied to any multi-threaded dispatcher to guarantee that no more than N coroutines run at once. For example, Dispatchers.IO.limitedParallelism(2) uses the same shared IO pool but limits it to two threads at a time — useful if, say, we're only allowed a limited number of open database connections. Any number of limited-parallelism views can be created on top of a dispatcher.
Run the following example and see what it prints:
import kotlinx.coroutines.*
fun main() {
runBlocking {
launch {
// context of the parent, main runBlocking coroutine
println("main : ${Thread.currentThread()}")
}
launch(Dispatchers.Unconfined) {
// will work on main thread, then switch to another one
println("Unconfined A : ${Thread.currentThread()}")
delay(1) // suspension point
println("Unconfined B : ${Thread.currentThread()}")
}
launch(Dispatchers.Default) {
// will get dispatched to DefaultDispatcher
println("Default : ${Thread.currentThread()}")
}
launch(newSingleThreadContext("MyThread1")) {
// will get its own new thread
println("new thread 1 : ${Thread.currentThread()}")
}
launch(newSingleThreadContext("MyThread2")) {
// will get a different new thread
println("new thread 2 : ${Thread.currentThread()}")
}
}
}Context inheritance
An important thing to know about the context is that everything except the job is inherited by default, unless we explicitly override it. That's convenient — we usually don't want to repeat the same dispatcher for every child coroutine. Here's how that plays out:
import kotlinx.coroutines.*
val handler = CoroutineExceptionHandler { _, exception ->
println("Got ${exception.message}")
}
fun main(): Unit = runBlocking(handler) {
println("root : ${this.coroutineContext}")
launch {
// uses the parent's context, but creates a new job
println("first : ${this.coroutineContext}")
launch {
// still the same context, another new job
println("first->same : ${this.coroutineContext}")
}
launch(Dispatchers.Default) {
// overrides the dispatcher
println("first->default : ${this.coroutineContext}")
launch(Dispatchers.IO) {
// overrides the dispatcher once more
println("first->default->IO: ${this.coroutineContext}")
}
}
}
}It prints something like this:
root : [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, BlockingCoroutine{Active}@1c655221, BlockingEventLoop@58d25a40]
first : [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, StandaloneCoroutine{Active}@626b2d4a, BlockingEventLoop@58d25a40]
first->same : [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, StandaloneCoroutine{Active}@14899482, BlockingEventLoop@58d25a40]
first->default : [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, StandaloneCoroutine{Active}@1c1817d6, Dispatchers.Default]
first->default->IO: [MainKt$special$$inlined$CoroutineExceptionHandler$1@73a8dfcc, StandaloneCoroutine{Active}@6809a6a3, Dispatchers.IO]Let's walk through what happened:
rootis the top-levelrunBlockingcoroutine. Its context holds ourhandler, its own job, and the default event-loop dispatcher (BlockingEventLoop).firstis a direct child ofroot. It inheritshandlerand the dispatcher fromroot, but gets a brand-new job of its own — jobs are never inherited, since each coroutine needs its own to track its individual lifecycle.first->sameis a child offirst. Again, everything is inherited except the job.first->defaultexplicitly overrides the dispatcher withDispatchers.Default. Noticehandleris still there — only the dispatcher changed.first->default->IOoverrides the dispatcher again, this time withDispatchers.IO, while still carryinghandlerdown from the root.
handler is inherited all the way down to every child, even though it only actually fires when an uncaught exception reaches the root coroutine — attaching it anywhere else in the hierarchy wouldn't stop it from being inherited, it just wouldn't do anything unless that coroutine happened to be the outermost one. Similarly, the root's dispatcher (BlockingEventLoop) propagates to every child that doesn't override it. And as shown above, it's fine to override the same element again at a deeper level.
Context switching
The example above shows how to run several concurrent jobs across different contexts, but in practice we often need something simpler: run one piece of work on a background thread, then pick up where we left off on the original thread once it's done.
Take a UI application that needs to load some data from disk before displaying it. Reading from disk can take a while, and running it directly on the main thread would freeze the UI while it waits. Launching a separate coroutine and manually waiting for its result would work, but it's more machinery than we need here. Kotlin's withContext solves exactly this: it runs a block of code with a different context — most often, a different dispatcher — and suspends the caller until that block finishes, then returns to the original context. The UI thread is never blocked while the disk read happens, but our code still reads top-to-bottom and gets its result back in order, with no callbacks and no separate launch.
import kotlinx.coroutines.*
fun main() = runBlocking {
// starts in main thread
println("root : ${Thread.currentThread()} ${this.coroutineContext}")
withContext(CoroutineName("A")) {
// continues in the same thread, but overrides coroutine name in context
println("coroutine A : ${Thread.currentThread()} ${this.coroutineContext}")
withContext(Dispatchers.IO) {
// jumps to the IO thread pool
println("coroutine A->IO : ${Thread.currentThread()} ${this.coroutineContext}")
}
}
// returns to the main thread, after the IO operation is done
println("root again : ${Thread.currentThread()} ${this.coroutineContext}")
}Unlike the earlier example, execution order here is guaranteed — you can confirm this by adding random delays before each println and comparing the output.
Conclusion
To sum up:
Context — every coroutine carries a
CoroutineContext, a set of keyed elements (similar to aMap) that controls how it runs.Job, ExceptionHandler, Dispatcher — the three most common elements. A coroutine can hold at most one of each; adding another of the same type silently overrides the previous one.
Dispatchers — decide which thread(s) run the code. Use
Dispatchers.Defaultfor CPU work,Dispatchers.IOfor blocking I/O, and avoidDispatchers.Unconfinedunless you have a specific reason to.limitedParallelism()caps concurrency on any of them.Context inheritance — everything except the job is inherited by children unless explicitly overridden, so you don't need to repeat dispatchers or handlers down the hierarchy.
Context switching —
withContextruns a block on a different context (usually a different dispatcher) and returns the result in sequence, without leaving structured concurrency.
All of this follows the same rules of structured concurrency we've already used: context propagates from parent to child, and each coroutine can override just the piece it needs.