skip to content

How are exceptions ultimately delivered out of coroutineScope versus supervisorScope, including how CoroutineExceptionHandler and async interact with each?

level: seniorimportance: nice to knowfreq 35%

answer

  1. coroutineScope rethrows to caller
  2. supervisorScope isolates: launch->CEH, async->await()
  3. CEH only for uncaught launch at a root
  4. async never uses CEH
  5. CancellationException is not a CEH failure

basics

~20 s

coroutineScope rethrows a child's failure to whoever called it. supervisorScope does not rethrow on its own; a failed launch child goes to the exception handler, and a failed async child shows up only when you await it.

solid answer

~30 s

coroutineScope: any child failure cancels the scope and is rethrown from the coroutineScope call, so the caller can try/catch it; a CoroutineExceptionHandler is ignored here because the exception is propagated by rethrow, not treated as uncaught. supervisorScope: a failing launch child is treated as uncaught and delivered to the CoroutineExceptionHandler in context (or the default handler), while siblings and the scope continue; a failing async child has its exception stored and rethrown at await(). CoroutineExceptionHandler only ever fires for root coroutines using launch-style propagation, never for async, and never for exceptions that coroutineScope rethrows.

code

kotlin · 8 lines
kotlin
val ceh = CoroutineExceptionHandler { _, e -> println("ceh: ${e.message}") }
suspend fun show() = withContext(ceh) {
    supervisorScope {
        launch { error("to-ceh") }            // delivered to ceh
        val d = async<Int> { error("boom") }
        try { d.await() } catch (e: Exception) { println("await: ${e.message}") }
    }
}

go deeper

for a junior

Knows coroutineScope rethrows and supervisorScope keeps siblings running.

for a middle

Distinguishes launch propagation from async exposure and where each delivers failures.

for a senior

Articulates that CEH only handles uncaught launch failures at a root and never async or coroutineScope-rethrown exceptions.

for a principal

Designs service-wide error policy: CEH + supervisorScope for isolation and observability vs coroutineScope fail-fast for request scopes, plus cancellation correctness.

## Two delivery channels Kotlin coroutines deliver exceptions through two mechanisms: 1. **Propagation (rethrow / uncaught)** — used by `launch` and by scope builders. The exception flows up the Job hierarchy and is either rethrown by an awaiting/structured boundary or, if it reaches a root, handed to a `CoroutineExceptionHandler` (CEH). 2. **Exposure** — used by `async`. The exception is *stored* in the `Deferred` and rethrown when you call `await()`. ## coroutineScope delivery `coroutineScope` is a structured boundary on a regular `Job`. When a child fails: - The scope's Job is cancelled, cancelling siblings. - `coroutineScope` **rethrows** the original exception to its caller. Because it is rethrown, you handle it with an ordinary `try/catch` around the `coroutineScope` call. A `CoroutineExceptionHandler` placed in the context is **not** invoked — CEH only handles *uncaught* exceptions at a root, and here the exception is caught/rethrown by the boundary. ```kotlin try { coroutineScope { launch { error("x") } } } catch (e: IllegalStateException) { // caught here } ``` ## supervisorScope delivery `supervisorScope` uses a `SupervisorJob`; child failures do **not** cancel the scope, so the scope does not rethrow on their behalf. Delivery depends on the builder of the failing child: - **`launch` child fails**: treated as **uncaught**. It is delivered to the `CoroutineExceptionHandler` present in the (super)scope's context; if there is none, it goes to the platform default (e.g. crashes / logs). Siblings keep running. - **`async` child fails**: exception is **stored** and rethrown at `await()`. The CEH is **never** used for async. ```kotlin val ceh = CoroutineExceptionHandler { _, e -> log(e) } withContext(ceh) { supervisorScope { launch { error("handled by ceh") } // -> ceh val d = async<Int> { error("only at await") } runCatching { d.await() } // -> caught here } } ``` ## Key rules to remember - **CEH fires only for launch-style (propagated) exceptions at a root scope.** Not for `async`, not for exceptions a `coroutineScope` rethrows. - **async always exposes via await().** Putting a CEH around async does nothing. - **coroutineScope = rethrow to caller; supervisorScope = isolate, then CEH (launch) or await (async).** - **CancellationException is special**: it is used for normal cancellation and is *not* treated as a failure that triggers CEH. ## Practical consequence In a long-lived service scope you usually install a CEH and use `supervisorScope` + `launch` for independent tasks so one task's crash is logged without taking down the rest. For request-scoped, fail-fast work you use `coroutineScope` and let the failure rethrow to your error-handling layer.

  • Why does a CoroutineExceptionHandler around coroutineScope never fire when a child throws?
    coroutineScope cancels the scope and rethrows the exception to its caller. CEH only handles uncaught exceptions at a root; here the boundary propagates it by rethrow, so it is caught by try/catch, not the handler.
  • Does installing a CoroutineExceptionHandler help with a failing async child?
    No. async stores its exception and rethrows it at await(); the CEH is never consulted for async. You must catch it at the await call.

saying these in an interview costs you the question

  • Saying a CEH catches async exceptions
  • Thinking coroutineScope routes failures to a CEH instead of rethrowing
  • Treating CancellationException as a CEH-triggering failure
  • Believing supervisorScope rethrows child failures from the scope call
  • Not distinguishing launch (propagate) from async (expose)

context