skip to content

When constructing CoroutineScope(SupervisorJob() + dispatcher) for a long-lived owner, why is SupervisorJob preferred over a plain Job, and what is the consequence at the scope level?

level: middleimportance: must knowfreq 62%

answer

  1. SupervisorJob = failure stays local to the child
  2. Plain Job = one crash cancels the whole scope
  3. Supervisor still needs CoroutineExceptionHandler for launch
  4. async stores exception until await()
  5. Supervision is per-level: use supervisorScope deeper

basics

~10 s

With a plain Job, one child crashing cancels the whole scope and its siblings. With SupervisorJob, a child's failure stays local — siblings keep running and the scope stays alive to launch more work.

solid answer

~50 s

The scope's Job decides how failures propagate among children. A regular Job() couples them: if any child throws an uncaught exception, it cancels the parent Job, which cancels all other children — the entire scope dies and can launch nothing more. A SupervisorJob() breaks upward and sideways failure propagation: a failing child cancels only itself, leaving siblings and the scope intact. For a long-lived owner (ViewModel, service) you almost always want this — one failed fetch shouldn't kill every coroutine the component owns. You still need an exception handler: an uncaught exception in a launch child of a supervisor goes to the CoroutineExceptionHandler in the context (or the default handler), so add one to the scope's context. Note SupervisorJob's semantics apply to its direct children in the scope, not automatically to grandchildren created via a normal child coroutineScope.

code

kotlin · 5 lines
kotlin
val handler = CoroutineExceptionHandler { _, e -> println("failed: ${'$'}{e.message}") }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)

scope.launch { error("boom") }      // fails, isolated by SupervisorJob
scope.launch { repeat(3) { println("still alive ${'$'}it") } } // keeps running

go deeper

for a junior

Knows SupervisorJob keeps siblings alive when one child fails, unlike a plain Job.

for a middle

Explains failure propagation precisely and that an exception handler is still required for launch children.

for a senior

Distinguishes scope-root supervision from inner coroutineScope/supervisorScope, and chooses handler placement deliberately.

for a principal

Standardizes scope-construction patterns (SupervisorJob + dispatcher + handler) and reasons about failure-domain boundaries across services.

## The Job inside a scope controls failure propagation When you write `CoroutineScope(SupervisorJob() + dispatcher)`, the `SupervisorJob` becomes the scope's root `Job`. Coroutines you `launch` directly in the scope are its children. The choice of root Job determines what happens when one of those children fails (throws an exception that isn't caught). ## Plain Job — failures are coupled With `CoroutineScope(Job() + dispatcher)`: - A child that throws **cancels its parent Job**. - Cancelling the parent **cancels all the parent's other children**. - The scope's Job moves to a cancelled state, so future `launch` calls on it start coroutines that are immediately cancelled. So one bad coroutine takes down the whole component's work. ## SupervisorJob — failures are isolated With `CoroutineScope(SupervisorJob() + dispatcher)`: - A failing **direct child** cancels only itself. - Siblings keep running; the scope stays active and can launch more coroutines. This matches a long-lived owner that runs many independent jobs. ## You still must handle the exception The failure doesn't vanish. For a `launch` child, an uncaught exception is delivered to the `CoroutineExceptionHandler` found in the context; if none is present, the default handler runs (e.g. prints/crashes depending on platform). So install one: ```kotlin private val handler = CoroutineExceptionHandler { _, e -> log.error("coroutine failed", e) } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) ``` For `async`, the exception is instead stored in the `Deferred` and rethrown on `await()`. ## Scope-level vs. inner supervision The supervisor semantics apply to the **direct children** of the SupervisorJob. If a child coroutine itself calls `coroutineScope { ... }` (a regular, non-supervisor scope), failures *inside that block* still propagate normally among that block's children. To get supervision deeper, use `supervisorScope { ... }`. Mixing these up is a common bug: people put SupervisorJob at the top and assume every level is supervised. ## Key APIs/keywords - `SupervisorJob()` — root Job that isolates child failures. - `Job()` — root Job that couples child failures. - `CoroutineExceptionHandler` — context element that receives uncaught exceptions from `launch`. - `supervisorScope { }` / `coroutineScope { }` — block-scoped variants. - `+` (context plus operator) — combines context elements.

  • If the scope uses SupervisorJob, where does an uncaught exception from a launch child go?
    To the CoroutineExceptionHandler in the scope's context if present, otherwise the platform default handler; it does not cancel siblings.
  • Does putting SupervisorJob at the scope root make every nested coroutine supervised?
    No. Only the scope's direct children are supervised. Nested coroutineScope blocks use normal coupling; use supervisorScope to supervise deeper levels.

SupervisorJob is a fuse box where each circuit has its own fuse: one blown fuse doesn't cut power to the rest of the house.

saying these in an interview costs you the question

  • Thinks SupervisorJob swallows or silences exceptions
  • Believes a CoroutineExceptionHandler is unnecessary with SupervisorJob
  • Assumes supervision applies recursively to all nesting levels
  • Cannot describe what a plain Job does on child failure
  • Confuses SupervisorJob with try/catch around launch

context