Trace exactly where an uncaught exception ends up when it propagates to the root of a regular (non-supervisor) coroutine scope, and why it cannot be caught at the call site.
answer
- propagation walks Job tree to the root Job
- root has no parent -> reported, not returned
- handler first, then Thread.UncaughtExceptionHandler
- handler only works at scope root
- launch is async -> call-site try/catch is useless
basics
~20 sThe exception bubbles up the parent chain to the top Job. There it is reported — handed to an exception handler if one exists, otherwise to the platform's default handler. It cannot be caught around the launch call because the body runs later.
solid answer
~40 sPropagation walks the `Job` tree from the failing child to the scope's root `Job`. At each level a failure cancels the parent and its siblings. Once it reaches a root `Job` (the one created for the scope, e.g. `CoroutineScope(Job())`), there is no parent to fail, so the exception is **reported as uncaught**: the runtime looks for a `CoroutineExceptionHandler` in the root coroutine's context and invokes it; if absent, it falls through to the thread's `Thread.UncaughtExceptionHandler` (printing a stack trace on the JVM). Crucially this is asynchronous: `launch` returned its `Job` immediately and the body executes on a dispatcher later, so a try/catch around `launch { ... }` never sees the throw. The only synchronous-looking exception delivery is `runBlocking`, which rethrows on its calling thread, and `async`, which defers the exception until `await()`.
code
kotlin · 9 linesimport kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, e ->
println("root reported: ${e.message}")
}
val scope = CoroutineScope(Job() + handler)
scope.launch { throw IllegalStateException("boom") }.join()
}go deeper
Knows the exception ends up printed/reported and is not returned.
Explains the call site can't catch it because the body runs later.
Traces the full path to the root Job and the handler-then-thread-handler reporting order, and why a non-root handler is inert.
Reasons about how scope topology determines reporting points and contrasts launch, async, and runBlocking delivery models.
## The propagation path Consider a failing leaf coroutine. Its exception travels **up the `Job` hierarchy**: ``` leaf (throws) -> child Job -> ... -> root Job (scope's Job) ``` At each non-root level: the failure cancels that level's parent, which cancels its other children. The original exception is carried upward until it reaches a **root `Job`** — the one with no coroutine parent, typically the `Job()` you passed to `CoroutineScope(Job())`, or the implicit root of `GlobalScope`/`runBlocking`. ## What 'reported' means at the root A root has no parent to fail, so the exception is delivered to a *reporting* mechanism, in this order: 1. **`CoroutineExceptionHandler`** in the context of the root coroutine (a `ContinuationInterceptor`-like element). If present, it is called with `(context, throwable)`. 2. Otherwise, the **`Thread.UncaughtExceptionHandler`** for the current thread (on the JVM this prints a stack trace; on Android it can crash the app). Note: a `CoroutineExceptionHandler` only takes effect at the **root** of a scope (or in `supervisorScope` children) — installing it on a non-root child does nothing, because the child's failure is just propagated upward, not reported there. ## Why the call site cannot catch it ```kotlin val scope = CoroutineScope(Job()) try { scope.launch { throw IllegalStateException() } // returns a Job NOW } catch (e: Exception) { /* unreachable */ } ``` `launch` is asynchronous. It schedules the body on a dispatcher and returns the `Job` synchronously. The `throw` happens **later**, on a (possibly different) thread, inside the coroutine continuation machinery. The call-site stack frame is long gone, so there is nothing to unwind into — hence the catch never fires. ## Contrast with the two synchronous-ish builders - **`runBlocking { ... }`** runs the body on the calling thread and **rethrows** any uncaught exception to that thread — so try/catch around `runBlocking` *does* work. - **`async { ... }`** stores the exception in its `Deferred` and rethrows it on **`await()`**; it does not report at the root the same way `launch` does (deferral is a separate topic). ```kotlin val handler = CoroutineExceptionHandler { _, e -> println("reported: ${e.message}") } val scope = CoroutineScope(Job() + handler) scope.launch { throw IllegalStateException("boom") }.join() // prints: reported: boom ``` ## Key takeaway Progation is up the `Job` tree to a *root*, where it becomes a *report*, not a *return*. Catch inside the body, install a handler at the root, or use `runBlocking`/`await` for synchronous delivery.
- Why does installing a CoroutineExceptionHandler on a non-root child do nothing?A child's failure is propagated up to its parent, not reported locally. Only at the scope root (or in supervisorScope children) is the exception reported, so only a root-context handler is consulted.
- Does a try/catch around `runBlocking { throw ... }` catch the exception?Yes. runBlocking runs the body on the calling thread and rethrows uncaught exceptions to that thread, so the surrounding try/catch works — unlike launch.
saying these in an interview costs you the question
- Saying a handler on any child coroutine catches the failure
- Claiming try/catch around launch works
- Confusing launch's root-reporting with async's await deferral
- Not knowing the fallback is the thread's uncaught-exception handler