Structured concurrency has one rule that surprises people the first time it bites: when a child fails, the parent cancels every other child.
That's deliberate. Usually it's what you want — if one part of a job is broken, finishing the rest is wasted work. But sometimes the children are genuinely unrelated, and one failing shouldn't take the others down with it.
SupervisorJob is the answer. It's also the thing people most reliably use
wrong, so let's get the trap out of the way early.
1. The default: one failure takes everything#
val scope = CoroutineScope(Dispatchers.Main.immediate)
scope.launch {
delay(100)
error("boom") // this one fails
}
scope.launch {
delay(200)
println("never printed")
}
The second coroutine never finishes. When the first throws, the exception
propagates to the scope's Job, which cancels itself and every other child.
Worse, the scope is now dead. A Job that has been cancelled doesn't come
back — every launch on that scope from here on returns immediately without
running anything. If that scope belonged to a screen, the screen has quietly
stopped working.
2. SupervisorJob makes children independent#
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
Same two coroutines, different outcome: the first fails, the second finishes.
A SupervisorJob propagates cancellation downward but not upward. Cancel
the parent and all children stop, as before. But a child failing is not allowed
to cancel the parent, so the siblings never hear about it.
One thing worth being precise about: supervision applies to direct children
only. If a supervised child launches its own children, those follow normal
Job rules among themselves. Supervision isn't inherited down the tree — it's
a property of one parent-child boundary.
3. You probably already have one#
Here's the part that changes how you read your own code:
// androidx.lifecycle, roughly
val ViewModel.viewModelScope: CoroutineScope
get() = CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
viewModelScope is already a supervisor scope. So is lifecycleScope.
Which means coroutines launched from a ViewModel already fail independently. An
animation that throws doesn't cancel your API call. That behaviour isn't
something you add — it's the default you inherited, and most code that reaches
for SupervisorJob() inside a ViewModel is solving a problem that was already
solved.
4. The trap: launch(SupervisorJob())#
This is the line to never write:
viewModelScope.launch(SupervisorJob()) { // don't
riskyWork()
}
It looks like "launch this coroutine with supervision". It does something else entirely.
When you pass a Job in a coroutine's context, that job becomes the parent
of the new coroutine — replacing the scope's job. So this coroutine is no longer
a child of viewModelScope. Three consequences follow, all bad:
- It leaks.
onClearedcancels the ViewModel's job, but this coroutine's parent is aSupervisorJobnobody holds a reference to. The work keeps running after the screen is gone. - Nothing waits for it. Anything relying on the scope's completion silently ignores it.
- It doesn't even supervise. Supervision protects siblings under the same
supervisor. Every
launch(SupervisorJob())creates a fresh one with exactly one child, so there are no siblings to protect.
You get all the cost of leaving the tree and none of the benefit you were after.
The same is true of withContext(SupervisorJob()), and for the same reason.
Any builder you hand a Job to will re-parent the coroutine. If you find
yourself passing a Job into a builder, that's the smell — the only things
that belong there are dispatchers, names, and exception handlers.
5. Where it does belong#
Building a scope you own:
class SyncEngine {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun stop() = scope.cancel() // you own the lifetime, so you must cancel it
}
Or supervisorScope for a single block:
suspend fun loadDashboard() = supervisorScope {
val profile = async { api.profile() }
val feed = async { api.feed() }
// feed failing doesn't cancel profile
DashboardState(
profile = profile.await(),
feed = runCatching { feed.await() }.getOrNull(),
)
}
supervisorScope is the one people forget. It gives independent failure for
exactly one block, stays inside structured concurrency, and needs no scope to
own or cancel. When you want supervision here and nowhere else, it's the
right tool — not a custom scope.
6. What supervision does not do#
This is the second-biggest misconception, and it's the one that crashes apps.
SupervisorJob stops a failure cancelling siblings. It does not stop the
exception.
An uncaught throw inside a supervised launch still travels to the
CoroutineExceptionHandler — and if there isn't one, to the thread's default
handler, which on Android means your app dies. Supervision changed nothing about
that.
So a supervisor scope that's genuinely allowed to fail needs a handler:
private val handler = CoroutineExceptionHandler { _, e ->
Napier.e("child failed", e)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + handler)
async is different again. A failing async holds its exception in the
Deferred and rethrows it at await(). Under supervision the sibling survives,
but you still have to await() inside a try or a runCatching — a handler
won't catch it for you, because it was never thrown into the scope.
Two separate questions, and it's worth asking them separately:
- Should this failure cancel the others? →
SupervisorJob/supervisorScope - What happens to the exception itself? → handler, or
tryaroundawait
Check yourself#
Five questions, aimed at the parts that catch people rather than the definitions. If you skimmed, they'll say so.
Check yourself
1You write viewModelScope.launch(SupervisorJob()) { risky() }. What happens?
Passing a Job into a builder makes that Job the parent, replacing the scope's. The coroutine leaves the tree: it leaks, nothing awaits it, and it supervises nothing — it's the only child under that fresh supervisor.
2A coroutine in a SupervisorJob scope throws, with no CoroutineExceptionHandler. What happens to your app?
Supervision decides whether siblings get cancelled. It does nothing about the exception, which still reaches the thread's default handler.
3viewModelScope is built from:
Which is why adding your own SupervisorJob inside a ViewModel is usually solving a problem you didn't have.
4A supervised child launches two children of its own. One grandchild fails. Then what?
Supervision is a property of one parent-child boundary, not something inherited down the tree. Below a supervised child, normal Job rules apply again.
5An async inside supervisorScope fails. When do you find out?
async holds the exception in its Deferred. A handler never sees it, because it was never thrown into the scope — so await() needs a try or a runCatching around it.
Answers are client-side only — nothing is recorded.
Wrapping up#
Check before you add. viewModelScope and lifecycleScope are already
supervisor scopes. Reaching for SupervisorJob() inside them usually means
misreading the problem.
Never pass a Job into a builder. launch(SupervisorJob()) re-parents the
coroutine out of the tree, leaks it, and supervises nothing.
Two tools, two shapes. A custom CoroutineScope(SupervisorJob() + ...) when
you own a lifetime; supervisorScope { } when you want it for one block.
Supervision is not error handling. It decides who else dies. It does not decide what happens to the exception, and forgetting that is how a supervised scope still crashes an app.
Until then, let's make this work.

