skip to content

On which thread/context is CoroutineExceptionHandler invoked, and how does it behave when multiple sibling coroutines fail?

level: seniorimportance: should knowfreq 35%

answer

  1. Runs synchronously in the failed coroutine's context
  2. Don't block — it stalls teardown
  3. First failure = primary; rest become suppressed
  4. Read throwable.suppressed when logging
  5. CancellationException never reported

basics

~20 s

The handler runs as part of the failing coroutine's machinery, using its context. If several children fail close together, only the first exception is reported to the handler and the others are attached to it as suppressed exceptions, so you do not lose them.

solid answer

~40 s

`handleException(context, throwable)` is invoked synchronously by the coroutine machinery from the context of the failed coroutine (the `context` argument is that coroutine's context, including its `Job`). It is not dispatched onto a separate thread by default, so a heavy or blocking handler can stall the failing path — keep it cheap. When **multiple children of the same parent fail**, structured concurrency cancels the others; the **first** exception is delivered to the handler, and subsequent ones are added to it via `Throwable.addSuppressed`, so they surface as suppressed entries rather than being silently dropped. `CancellationException` triggered by that cancellation is ignored (not reported). This is why you should inspect `throwable.suppressed` when logging.

code

kotlin · 11 lines
kotlin
val handler = CoroutineExceptionHandler { ctx, e ->
    println("primary on ${ctx[CoroutineName]}: $e")
    e.suppressed.forEach { println("  suppressed: $it") }
}
runBlocking {
    val scope = CoroutineScope(Job() + handler)
    scope.launch {
        launch { throw RuntimeException("A") }
        launch { throw RuntimeException("B") }
    }.join()
}

go deeper

for a junior

Knows the handler logs the failure and should stay simple.

for a middle

Knows multiple failures aggregate and the handler runs in the coroutine's context.

for a senior

Explains synchronous invocation, suppression aggregation, and CancellationException exclusion precisely.

for a principal

Builds robust observability: non-blocking handler, hand-off to a reporting pipeline, and surfacing suppressed exceptions in telemetry.

## Invocation context When a root `launch` coroutine fails uncaught, the machinery calls `handler.handleException(context, throwable)`: - `context` is the **failed coroutine's** `CoroutineContext` (you can read its `Job`, `CoroutineName`, etc.). - It runs **synchronously** on whatever thread the failure-handling code is executing on — it is not posted to a new dispatcher. So a blocking or slow handler delays teardown. Keep the handler small (log, increment a metric) and offload heavy work elsewhere. ## Multiple sibling failures and suppression Under a regular `Job`, when one child fails it cancels the parent and the siblings. Several may throw around the same time. The runtime delivers the **first** exception to the handler as the primary `throwable`, and **aggregates** the others using `Throwable.addSuppressed(...)`. So: ```kotlin val handler = CoroutineExceptionHandler { _, e -> println("primary: $e") e.suppressed.forEach { println("suppressed: $it") } } ``` This prevents losing information when a cluster of coroutines fail together. `CancellationException`s produced by the cascading cancellation are **not** reported and **not** added as suppressed. ## CancellationException is special The handler is never invoked for a `CancellationException` — cancellation is normal, cooperative control flow, not a failure. ## Practical guidance - Always log `throwable` **and** its `suppressed` array. - Don't block in the handler; if you need async I/O for reporting, hand off to a dedicated dispatcher/queue. - Don't assume only one exception occurred in a fan-out. Key APIs/keywords: `CoroutineExceptionHandler.handleException`, `CoroutineContext`, `Job`, `Throwable.addSuppressed`, `Throwable.suppressed`, `CancellationException`, `SupervisorJob`.

  • Why should the handler be lightweight?
    It runs synchronously on the failure path, not on a separate dispatcher. Blocking or slow work there delays coroutine teardown and can hold the failing thread.
  • Where do the second and third sibling exceptions go?
    They are attached to the first (primary) throwable via addSuppressed and appear in throwable.suppressed; they are not separately delivered to the handler.

saying these in an interview costs you the question

  • Assuming the handler runs on a fresh/background thread
  • Doing heavy/blocking work in the handler
  • Logging only the primary and ignoring suppressed exceptions
  • Thinking each sibling failure invokes the handler separately

context