Let's Make SupervisorJob Work: Independent Failure Without Breaking the Tree

One child throws and the whole scope dies. SupervisorJob fixes that — but you probably already have one and didn't know, and the way most people reach for it does nothing except break structured concurrency.

Let's Make SupervisorJob Work: Independent Failure Without Breaking the Tree

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#

Kotlin
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#

Kotlin
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:

Kotlin
// 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:

Kotlin
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. onCleared cancels the ViewModel's job, but this coroutine's parent is a SupervisorJob nobody 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:

Kotlin
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:

Kotlin
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:

Kotlin
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 try around await

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?

2A coroutine in a SupervisorJob scope throws, with no CoroutineExceptionHandler. What happens to your app?

3viewModelScope is built from:

4A supervised child launches two children of its own. One grandchild fails. Then what?

5An async inside supervisorScope fails. When do you find out?

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.

Keep reading

More breakdowns

Kotlin & Core Concepts

Let's Make Coroutines Work: What is a Coroutine?

Threads block, and blocked threads freeze apps. Here's what a coroutine actually is, what a suspension point does, and why you can run 100,000 of them on a single thread.

· 2 min read
Design System

Let's Make an Avatar System Work: Layering, Negative Spacing, and Constrained APIs

Two composables, three decisions. An image drawn on top of initials replaces an if/else. A negative number replaces a custom layout. And an enum stops the call site inventing its own sizes.

· 4 min read
Problem Solving

Let's Make the Kangaroo Problem Work: Algebra Instead of a Loop

Two kangaroos, different starting points, different jump distances. Do they ever land on the same spot at the same time? The instinct is to simulate. Four lines of algebra answer it in O(1).

· 3 min read