Contrast how async and launch handle a thrown exception. Why does CoroutineExceptionHandler work for launch but not for a root async's deferred exception?
answer
- launch = uncaught -> handler
- async = deferred -> await() is yours
- handler only on root coroutine
- handler is not a try/catch substitute for await()
- SupervisorJob needed for handler to actually run on launch child
basics
~10 slaunch reports failures immediately as 'uncaught', so a CoroutineExceptionHandler can catch them. async stores the exception for await(), so it is 'caught' by you at await() and the handler is not invoked for it.
solid answer
~40 slaunch produces a Job with no result; an unhandled failure is treated as uncaught and routed to the CoroutineExceptionHandler installed in the scope (or the default handler). async produces a Deferred; its exception is considered *handled-on-demand* via await(), so it is NOT delivered to the CoroutineExceptionHandler — you are expected to catch it at await(). Important nuance: the structural cancellation of the parent (when an async is a root child of a normal Job) still happens regardless, and the exception that ultimately cancels the parent CAN reach the CoroutineExceptionHandler at the top of that chain. But the Deferred's own await() path is your responsibility. Rule of thumb: CoroutineExceptionHandler is for uncaught exceptions in launch-style coroutines and at the root of a failing tree; it is not a substitute for try/catch around await().
code
kotlin · 13 linesimport kotlinx.coroutines.*
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, e -> println("handler: $e") }
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch { throw RuntimeException("launch-fail") } // handler fires
val d = scope.async { throw RuntimeException("async-fail") }
runCatching { d.await() }.onFailure { println("caught at await: $it") } // handler does NOT fire for this
delay(100)
}go deeper
Knows launch failures are reported eagerly and async failures wait for await().
Explains why CoroutineExceptionHandler handles launch but not the async await() path, and where the handler must live.
Distinguishes propagation-to-root vs await() path and warns about dropped exceptions from unawaited async.
Sets scope-level error policy (handler at root + supervisor topology) and audits for swallowed async failures.
## launch vs async on failure | Aspect | `launch` → `Job` | `async` → `Deferred<T>` | |---|---|---| | Result | none | value via `await()` | | Where failure surfaces | immediately, as *uncaught* | deferred, re-thrown at `await()` | | `CoroutineExceptionHandler` | yes, handles it | not for the await() path | | Your obligation | optional handler | try/catch around `await()` | ## Why the handler fires for launch When a `launch` coroutine fails and the failure is not handled by a parent `SupervisorJob`, it is treated as an **uncaught exception**. The coroutines machinery routes uncaught exceptions to the `CoroutineExceptionHandler` from the context, or to a default (which on JVM goes to the thread's uncaught handler). ```kotlin val handler = CoroutineExceptionHandler { _, e -> println("handled: $e") } val scope = CoroutineScope(SupervisorJob() + handler) scope.launch { throw RuntimeException("boom") } // handler fires ``` ## Why it does NOT fire for a supervised async For `async`, the exception is **encapsulated in the `Deferred`**. The library considers it the consumer's job to observe it via `await()`, so it is not delivered to `CoroutineExceptionHandler`: ```kotlin val scope = CoroutineScope(SupervisorJob() + handler) val d = scope.async { throw RuntimeException("boom") } // handler does NOT fire; you must do: d.await() // throws here -> catch it yourself ``` ## The root-of-tree nuance `CoroutineExceptionHandler` only acts on the **root** coroutine of a scope, not on children. So even with `launch`, only the topmost coroutine's handler matters. And if an `async` is a non-supervised root child, its failure cancels the parent and the propagated exception can reach the handler at the very top of the structured tree — but that is the *propagation* path, not the *await()* path. Never rely on `CoroutineExceptionHandler` instead of catching at `await()`. Key APIs: `launch`, `async`, `Job`, `Deferred`, `await()`, `CoroutineExceptionHandler`, `SupervisorJob`.
- Why must the CoroutineExceptionHandler be in the scope/root context, not on a child launch?Children delegate handling to their parent; only the root coroutine of the failing tree consults its CoroutineExceptionHandler.
- If you forget to await a failed supervised async, where does the exception go?It stays stored in the Deferred and is effectively dropped — it is not reported to the CoroutineExceptionHandler, which can silently hide errors.
saying these in an interview costs you the question
- Claiming CoroutineExceptionHandler catches a supervised async's await() exception
- Installing the handler on a child coroutine and expecting it to fire
- Treating CoroutineExceptionHandler as a try/catch around await()
- Forgetting that a launch child needs SupervisorJob/root to actually reach the handler without cancelling everything