A developer writes launch(SupervisorJob()) { ... } inside a scope and expects failure isolation. Why is this a bug, and what actually happens?
answer
- Job in context = new parent, overrides the scope
- launch(SupervisorJob()) detaches from the scope → leak
- Supervisor isolates SIBLINGS under a COMMON parent
- One child has no siblings to isolate
- Fix: supervisorScope { } or CoroutineScope(SupervisorJob())
basics
~20 sPassing a SupervisorJob into launch makes it the coroutine's parent job, which breaks the link to the surrounding scope. The work escapes structured concurrency: the scope no longer waits for it or cancels it, and supervision doesn't apply the way they expect.
solid answer
~40 sWhen you pass a Job (including SupervisorJob()) as the context to launch or async, that Job becomes the parent of the new coroutine — overriding the scope's own Job. This detaches the coroutine from the surrounding scope's structured concurrency: cancelling the scope will NOT cancel this coroutine, and the scope will NOT await it. You also typically never hold a reference to that throwaway SupervisorJob, so it can leak. The supervisor 'isolation' people want is about isolating SIBLINGS from each other, which requires the supervisor to be the COMMON parent of those siblings — achieved with supervisorScope { } or a scope built on SupervisorJob(), not by handing a fresh SupervisorJob to a single launch. The correct fixes are supervisorScope { launch{}; launch{} } or CoroutineScope(SupervisorJob()).
code
kotlin · 8 lines// BUG: detaches from scope, no sibling to isolate
scope.launch(SupervisorJob()) { risky() }
// FIX: supervisor is the common parent of the siblings
suspend fun ok() = supervisorScope {
launch { a() }
launch { b() } // survives a()'s failure
}go deeper
May not spot the bug but recognizes supervisorScope as the idiomatic tool.
Knows passing a Job changes the parent and that this detaches from the scope.
Explains the leak, the dangling Job, and that supervision is about siblings under a common parent.
Connects it to lifecycle ownership and cancellation guarantees, and would flag/lint this pattern in review.
## The anti-pattern ```kotlin scope.launch(SupervisorJob()) { // <-- bug riskyWork() } ``` This looks like it requests supervision, but it does the wrong thing. ## What passing a Job to launch/async does The coroutine builders accept a `CoroutineContext`. If that context contains a `Job`, **that Job becomes the parent** of the new coroutine, *replacing* the Job inherited from the scope. So `launch(SupervisorJob())` makes the brand-new `SupervisorJob()` the parent — **not** `scope`. Consequences: - **Detached from the scope.** `scope.cancel()` cancels the scope's Job, but this coroutine's parent is the throwaway supervisor, so it **keeps running** — a structured-concurrency leak. - **Scope won't await it.** `coroutineScope`/`supervisorScope` join children of *their* Job; this child belongs to a different Job, so it is not awaited. - **Dangling Job.** You usually don't keep a reference to that `SupervisorJob()`, so nobody can cancel or join it. ## Why it misunderstands supervision A `SupervisorJob` isolates **siblings** — but only siblings that share it as their **common parent**. Handing a *fresh* supervisor to a *single* `launch` creates a one-child tree; there are no siblings to isolate. The developer wanted *several* sibling tasks to not cancel each other, which requires the supervisor to sit **above all of them**. ## Correct patterns ```kotlin // (1) Bounded fan-out with isolation between siblings suspend fun work() = supervisorScope { launch { taskA() } // failure here launch { taskB() } // does not cancel this } // (2) Long-lived owner val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) scope.launch { taskA() } scope.launch { taskB() } ``` In both, the supervisor is the **common parent**, so siblings are isolated *and* the work stays inside structured concurrency. ## A related correct use: child Job for cancellation scoping Passing a Job to launch is not always wrong — e.g. `launch(Job())` is occasionally used deliberately to detach. But doing it *for supervision* is a misunderstanding. ## Key APIs - `launch(context)` / `async(context)` — context's Job becomes the parent. - `SupervisorJob()`, `supervisorScope { }`, `CoroutineScope(SupervisorJob())` - `Job()`, `scope.cancel()`
- Does scope.cancel() cancel a coroutine started with launch(SupervisorJob())?No. The coroutine's parent is the passed-in SupervisorJob, not the scope's Job, so it is detached and keeps running.
- Is passing a Job to launch ever legitimate?Yes — e.g. launch(Job()) to deliberately detach a sub-task with its own cancellation lifecycle. The mistake is doing it expecting sibling supervision.
It's like giving a single employee their own private company to 'protect the team' — there is no team left to protect, and now nobody can recall that employee.
saying these in an interview costs you the question
- Insisting launch(SupervisorJob()) gives sibling isolation
- Not realizing the passed Job replaces the scope's parent Job
- Missing the structured-concurrency leak / dangling Job
- Confusing isolating one child with isolating siblings