skip to content

Where do uncaught exceptions from launch children go under a SupervisorJob, and how do you handle them correctly?

level: seniorimportance: should knowfreq 50%

answer

  1. Handler fires only at the root of the failure subtree
  2. SupervisorJob makes direct children roots -> per-child handler works
  3. launch -> CoroutineExceptionHandler; async -> rethrow on await()
  4. Handler is last-resort: cannot recover/resume
  5. CancellationException never reaches the handler

basics

~10 s

A launch child's uncaught exception under a SupervisorJob goes to a CoroutineExceptionHandler. The handler must be on the child (or its scope), not added when you call launch on a coroutineScope's child.

solid answer

~40 s

Under a SupervisorJob, each launch child is treated as a root for exception handling, so an uncaught exception is delivered to the nearest `CoroutineExceptionHandler` found in that child's context — typically installed on the scope (`CoroutineScope(SupervisorJob() + handler)`) or directly on the child (`launch(handler) { }`). A subtle rule: a CoroutineExceptionHandler only takes effect at the **root** of the failure-propagation subtree. With a regular Job, a handler installed on an inner `launch` is ignored (the exception propagates up to the root); but a SupervisorJob makes its direct children roots, so a per-child handler works there. The handler should only log/report — by the time it runs the coroutine has already failed; you cannot recover or resume it. async children differ: their exception is captured and rethrown by `await()`, so a CoroutineExceptionHandler never sees them.

go deeper

for a junior

Knows there is a CoroutineExceptionHandler for uncaught launch exceptions.

for a middle

Can install a handler on a supervisor scope and knows async needs try/catch on await.

for a senior

Explains the root-of-subtree rule and why SupervisorJob makes per-child handlers effective.

for a principal

Designs a consistent failure-reporting strategy across scopes, distinguishing cancellation from failure and choosing launch vs async accordingly.

## The propagation model Kotlin distinguishes **where an exception is reported** from where it is thrown. An uncaught exception travels up the Job tree to the **root coroutine** of its failure-propagation subtree, and only there is it handed to a `CoroutineExceptionHandler` (an element of `CoroutineContext`). Consequences: - A handler installed on a **non-root** inner `launch` is **ignored** (under a regular Job). - The handler is the **last resort** — it runs after the coroutine has already failed; you cannot resume it. ## What SupervisorJob changes A SupervisorJob makes each of its **direct children a root** for failure propagation. So a `CoroutineExceptionHandler` placed on a direct child (or on the supervisor scope) **does** get invoked: ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val handler = CoroutineExceptionHandler { _, e -> println("handled: ${e.message}") } val scope = CoroutineScope(SupervisorJob() + handler) scope.launch { throw RuntimeException("boom") } // -> handled: boom delay(50) } ``` Placing the handler per child also works: `scope.launch(perChildHandler) { ... }`. ## launch vs async under supervision - **launch**: uncaught exception -> CoroutineExceptionHandler (or thread's default uncaught handler if none). - **async**: the exception is **stored in the Deferred** and rethrown when you call `await()`. A CoroutineExceptionHandler is NOT consulted for it. So you must `try/catch` around `await()`. ```kotlin supervisorScope { val d = async { throw IllegalStateException("x") } try { d.await() } catch (e: IllegalStateException) { println("caught on await") } } ``` ## CancellationException is special A `CancellationException` is treated as normal cooperative cancellation, never reported to a CoroutineExceptionHandler, and never cancels the parent. This is true regardless of Supervisor vs regular Job. ## Practical guidance - Install one CoroutineExceptionHandler on the supervisor **scope** to log/report any child failure. - For results you need, prefer `async` + `try/catch await()` (or wrap the body in try/catch). - Do not rely on the handler to recover state — design children to fail cleanly. - Remember a handler on an inner non-root launch under a **regular** Job is dead code; this trips people up.

  • Why is a CoroutineExceptionHandler on an inner launch under a regular Job ignored?
    Because the exception propagates up to the root coroutine before being reported; only the root's handler is consulted. SupervisorJob changes this by making its direct children roots.
  • Does a CoroutineExceptionHandler catch exceptions from async?
    No. async stores the exception in the Deferred and rethrows it from await(); the handler is never consulted for async results.

saying these in an interview costs you the question

  • Putting a handler on an inner non-root launch and expecting it to fire (regular Job)
  • Expecting CoroutineExceptionHandler to catch async failures
  • Thinking the handler can recover/resume the coroutine
  • Expecting CancellationException to reach the handler
  • Confusing reporting location with throw location

context