skip to content

Why does a CoroutineExceptionHandler placed on a child launch have no effect, while the same handler on the scope works?

level: middleimportance: must knowfreq 60%

answer

  1. Children delegate handling upward; only the root acts
  2. Handler effective at scope/root, ignored on a child
  3. Regular Job: child failure cancels parent
  4. SupervisorJob: direct children are roots for handling
  5. For local handling use try/catch, not a child handler

basics

~20 s

Child coroutines do not handle their own failures; they pass them up to the parent. So a handler set on a child is ignored. Only the handler on the root coroutine or scope, where failures finally land, actually runs.

solid answer

~50 s

Exception handling in structured concurrency is delegated upward. A failing child coroutine first cancels its parent and propagates the throwable up the Job hierarchy; only the **root** coroutine (the one whose parent is the scope's Job) actually decides what to do with an uncaught exception. Therefore the `CoroutineExceptionHandler` is consulted only from the context of that root coroutine — effectively from the scope's context. Installing the handler on an inner `launch(handler) { ... }` that is itself a child is useless: that child won't invoke its own handler; it forwards the exception to its parent. To make the handler effective you put it in the `CoroutineScope(...)` context (or on a top-level/root `launch` directly in the scope). With a `SupervisorJob`, each direct child of the supervisor is treated as a root for handling purposes, so a handler on those children/the supervisor scope can fire.

code

kotlin · 9 lines
kotlin
val handler = CoroutineExceptionHandler { _, e -> println("got $e") }

// No effect: handler on a nested child
CoroutineScope(Job()).launch {
    launch(handler) { throw RuntimeException("x") }
}

// Works: handler in the scope context
CoroutineScope(Job() + handler).launch { throw RuntimeException("x") }

go deeper

for a junior

Knows the handler belongs on the scope, not deep inside, even if the reason is fuzzy.

for a middle

Explains that children delegate to parents and only the root invokes the handler.

for a senior

Articulates the Job tree, root coroutine concept, and how SupervisorJob redefines the handling boundary.

for a principal

Designs scope topology so handling lands predictably, choosing supervisor vs regular Job per failure-isolation requirements.

## The rule Kotlin coroutines use **structured concurrency**: every coroutine has a `Job`, and child Jobs form a tree under the scope's Job. When a coroutine fails with a non-`CancellationException`: 1. It cancels its parent (regular `Job`) — bidirectional failure propagation. 2. The exception travels up to the **root** coroutine of that subtree. 3. The root looks up a `CoroutineExceptionHandler` in its context and calls `handleException`. If none is present, the JVM default uncaught handler runs. **Children never handle their own exceptions.** A child delegates the decision to its parent. So a handler attached to a child is never invoked. ## Why placement matters ```kotlin val handler = CoroutineExceptionHandler { _, e -> println("got $e") } val scope = CoroutineScope(Job()) // WRONG: handler on a child has no effect scope.launch { launch(handler) { throw RuntimeException("boom") } // ignored } // RIGHT: handler on the scope (root context) val scope2 = CoroutineScope(Job() + handler) scope2.launch { throw RuntimeException("boom") } // handler runs ``` In the WRONG case, the inner `launch` is a child of the outer `launch`; it forwards the exception upward, and the outer (root) coroutine has no handler — so the default handler runs and the inner `handler` is ignored. ## SupervisorJob nuance With `SupervisorJob`, failure does **not** propagate from child to parent. Each direct child of a supervisor scope is effectively a **root for exception handling**, so a `CoroutineExceptionHandler` in the supervisor scope (or on those direct children) is honored, and one failing child does not cancel its siblings. ## Practical guidance - Put the handler in the **scope context** that owns the root coroutines. - Or attach it to a **top-level launch** that is a direct child of the scope. - Don't bother attaching it to nested children — wrap their bodies in `try/catch` if you need local handling. Key APIs/keywords: `Job`, `SupervisorJob`, `CoroutineScope`, `launch`, `CoroutineExceptionHandler`, structured concurrency, root coroutine.

  • How does using SupervisorJob change where the handler is effective?
    Each direct child of a SupervisorJob acts as a root for handling, so a handler in the supervisor scope (or on those direct children) fires, and a failing child won't cancel its siblings.
  • If you need to handle a nested child's failure locally, what do you use?
    A try/catch around the suspending body of that child; the CoroutineExceptionHandler is not the right tool for in-place recovery.

saying these in an interview costs you the question

  • Believing a handler works wherever you attach it
  • Not knowing children delegate handling to the parent
  • Confusing local try/catch recovery with the global handler
  • Ignoring how SupervisorJob changes the root boundary

context