skip to content

When would you use supervisorScope { } versus constructing a CoroutineScope with SupervisorJob(), and how do they differ in lifecycle?

level: middleimportance: should knowfreq 55%

answer

  1. supervisorScope = bounded, suspends until children done
  2. SupervisorJob() scope = long-lived, you cancel() it
  3. Block-body exception propagates; launched-child exception goes to handler
  4. viewModelScope/lifecycleScope are SupervisorJob-backed
  5. async stores exception until await()

basics

~20 s

Use supervisorScope { } for a temporary block of parallel tasks that should not cancel each other. Use a CoroutineScope built with SupervisorJob() for a long-lived owner, like a screen or service, that you cancel later.

solid answer

~50 s

supervisorScope { } is a suspending builder: it creates a child scope backed by a supervisor Job, runs the block, and suspends until all children complete before returning — like coroutineScope but without sibling cancellation on failure. It is scoped to that block and rethrows any exception thrown directly in the block body. CoroutineScope(SupervisorJob() + dispatcher) creates a standalone, long-lived scope you store in a property (e.g. ViewModel, service); you launch into it over time and explicitly call scope.cancel() in cleanup. Lifecycle difference: supervisorScope auto-joins/auto-completes within the call; a manually built scope lives until you cancel it. Both isolate child failures from siblings. A subtle trap: in supervisorScope, an exception in a directly-launched child is NOT rethrown by the block — it is delivered to the child's handler — whereas an exception thrown in the block's own code IS propagated out.

code

kotlin · 11 lines
kotlin
class Repo {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    fun start() = scope.launch { /* survives sibling failures */ }
    fun close() = scope.cancel()
}

suspend fun aggregate() = supervisorScope {
    val x = async { fetchX() }
    val y = async { fetchY() }
    runCatching { x.await() }.getOrNull() to y.await()
}

go deeper

for a junior

Can pick supervisorScope for a temporary parallel block and a SupervisorJob scope for something long-lived.

for a middle

Explains the lifecycle difference (auto-join vs manual cancel) and that both isolate siblings.

for a senior

Articulates the propagation subtlety between block-body and launched-child exceptions and ties it to viewModelScope.

for a principal

Designs scope ownership and teardown across a system, avoiding leaks and choosing isolation boundaries deliberately.

## Two tools, same isolation policy Both give you **supervisor semantics** (a failing child does not cancel its siblings), but they differ in **lifecycle and ergonomics**. ## supervisorScope { } — a bounded, suspending builder `supervisorScope { }` is a **suspending function** that: 1. Creates a new child scope whose Job is a supervisor. 2. Runs the lambda you pass. 3. **Suspends until every coroutine launched inside completes** (success or failure), then returns. It mirrors `coroutineScope { }`, except failures of children launched inside are isolated. Use it inside a suspend function when you fan out several independent sub-tasks and want partial results to survive. ```kotlin suspend fun loadDashboard(): Dashboard = supervisorScope { val a = async { loadFeed() } // may fail val b = async { loadProfile() } // independent Dashboard( feed = runCatching { a.await() }.getOrNull(), profile = b.await() ) } ``` ### Propagation subtlety inside supervisorScope - An exception thrown **directly in the block body** (e.g. `b.await()` rethrowing) **propagates out** of `supervisorScope` to the caller. - An exception in a **directly-launched child** (`launch { throw ... }`) is treated as that child's failure and is delivered to its `CoroutineExceptionHandler`, **not** rethrown by the block. - For `async`, the exception is stored in the `Deferred` and only thrown when you call `await()`. ## CoroutineScope(SupervisorJob()) — a long-lived owner When a component lives across many operations (a UI screen, a background service), you build a scope you control: ```kotlin class FeedService(dispatcher: CoroutineDispatcher = Dispatchers.IO) { private val scope = CoroutineScope(SupervisorJob() + dispatcher) fun refresh() = scope.launch { /* one independent task */ } fun shutdown() = scope.cancel() // cancels all children downward } ``` Here you `launch` into the scope over time, and you are responsible for calling `cancel()` during teardown. Android's `viewModelScope` and `lifecycleScope` are built exactly this way (on a `SupervisorJob`). ## Choosing between them | Situation | Use | |---|---| | Fan-out inside a single suspend function, want to join | `supervisorScope { }` | | Long-lived owner that launches work over time | `CoroutineScope(SupervisorJob() + ...)` | ## Key APIs - `supervisorScope { }`, `coroutineScope { }` - `SupervisorJob()`, `CoroutineScope(...)`, `scope.cancel()` - `async` / `await`, `runCatching`, `CoroutineExceptionHandler`

  • In supervisorScope, does an exception thrown by launch { } propagate to the caller of supervisorScope?
    No. A directly-launched child's failure is isolated and routed to its CoroutineExceptionHandler; only exceptions thrown in the block body itself propagate out.
  • Why is viewModelScope backed by a SupervisorJob?
    So that one failing coroutine on a screen does not cancel all the other in-flight work on that same screen.

supervisorScope is a rented meeting room you must vacate when the meeting ends; a SupervisorJob scope is an office you keep until you decide to move out.

saying these in an interview costs you the question

  • Saying both are identical with no lifecycle distinction
  • Claiming supervisorScope returns immediately without waiting for children
  • Forgetting to cancel() a manually built long-lived scope (leak)
  • Thinking a launch failure inside supervisorScope is rethrown to the caller

context