skip to content

A developer writes scope.launch(SupervisorJob()) { ... } expecting that child's failures to be isolated. Why is this often a mistake?

level: seniorimportance: should knowfreq 35%

answer

  1. Passing a Job to launch makes IT the parent -> detaches from scope
  2. scope.cancel() then won't cancel the detached coroutine -> leak
  3. SupervisorJob belongs on the scope, or use supervisorScope { }
  4. To isolate one child, pass a CoroutineExceptionHandler, not a Job
  5. Any Job passed to launch breaks structured concurrency

basics

~10 s

Passing a SupervisorJob into launch makes it the coroutine's parent job, which breaks structured concurrency: the new coroutine is no longer tied to the scope, so cancelling the scope won't cancel it.

solid answer

~40 s

When you pass a Job (including a SupervisorJob) as the context to `launch(...)` or `async(...)`, it becomes the **parent Job of that coroutine**, overriding the scope's Job in the child's context. The coroutine is then a child of *that* Job, not of the scope — so it escapes the scope's structured-concurrency tree: cancelling or completing the scope no longer cancels this coroutine, and the scope won't wait for it. This is a classic leak. SupervisorJob is meant to be the **scope's** Job (`CoroutineScope(SupervisorJob() + dispatcher)`) or to be obtained structurally via `supervisorScope { }`, not threaded into individual `launch` calls. If you only want a single child's exceptions handled independently, pass a `CoroutineExceptionHandler`, not a fresh Job.

code

kotlin · 12 lines
kotlin
// WRONG: detaches from scope
scope.launch(SupervisorJob()) { work() }

// RIGHT: supervisor on the scope
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope.launch { work() }

// RIGHT: structured region
supervisorScope { launch { work() } }

// RIGHT: isolate one child's exception
scope.launch(CoroutineExceptionHandler { _, e -> log(e) }) { work() }

go deeper

for a junior

Recognizes that something is off but may not explain reparenting.

for a middle

Knows the coroutine detaches from the scope and won't be cancelled.

for a senior

Explains context merging, the parent-Job override, and gives the correct alternatives (scope Job, supervisorScope, handler).

for a principal

Generalizes the rule (Jobs belong to scopes), spots the leak in review, and sets a team convention against passing Jobs to launch.

## How context merging works in launch/async `launch(context) { }` computes the child's context as: scope context **+** the passed `context`, with the new coroutine's own Job. But the **parent Job** for the new coroutine is taken from whatever Job is in the *combined* context — and if you passed a Job, **that** is used as the parent, not the scope's Job. ## The pitfall ```kotlin val scope = CoroutineScope(Job() + Dispatchers.Default) scope.launch(SupervisorJob()) { // <-- mistake // parent is the passed SupervisorJob, NOT scope's Job } scope.cancel() // does NOT cancel the launched coroutine ``` The coroutine is now parented to the throwaway `SupervisorJob()`. Effects: - **Cancellation leak**: `scope.cancel()` won't reach it. - **Lifecycle leak**: the scope (and any `coroutineScope`) won't await it. - The fresh SupervisorJob has no reference held anywhere, so you can't cancel it either — it runs to completion detached. ## What people actually wanted Usually one of: 1. **A resilient scope** — install the supervisor on the scope: `CoroutineScope(SupervisorJob() + dispatcher)`. 2. **A structured supervisor region** — use `supervisorScope { launch { } ; launch { } }`. 3. **Just isolate one child's exceptions** — pass a `CoroutineExceptionHandler`, not a Job: `scope.launch(handler) { }`. The handler is a context element that does not reparent the coroutine. ## Why a plain Job() passed to launch is also wrong Same reasoning: any Job in the launch context becomes the parent and detaches the coroutine. The only structured way to introduce a new Job is by creating a new scope (or builder) around it, not by passing it to `launch`. ## Quick rule > A Job belongs to a **scope**, not to a single `launch`. Pass non-Job context elements (dispatcher, name, handler) to `launch`; put the Job on the scope.

  • Is passing a plain Job() to launch any better than passing a SupervisorJob()?
    No — both reparent the coroutine to the passed Job and detach it from the scope. The difference between Job and SupervisorJob is only about child-failure propagation, not about the reparenting problem.
  • If I just want one child's failure not to kill the scope, what should I pass?
    A CoroutineExceptionHandler (or wrap the body in try/catch), or run that child inside a supervisorScope. Don't pass a new Job.

It's like giving a tour-group member a different bus to ride home — when the guide leaves, that person is no longer accounted for.

saying these in an interview costs you the question

  • Believing launch(SupervisorJob()) keeps the coroutine inside the scope
  • Not realizing the passed Job becomes the parent
  • Suggesting passing Job() to launch as a normal pattern
  • Confusing reparenting with exception isolation
  • Ignoring the resulting cancellation/lifecycle leak

context