skip to content

When a parent and several children fail around the same time, which exception is reported at the root, and what happens to the others?

level: principalimportance: nice to knowfreq 25%

answer

  1. first failure becomes the primary reported exception
  2. later failures attached via addSuppressed()
  3. exactly one exception reaches the handler
  4. teardown CancellationExceptions are filtered out
  5. racing winner is non-deterministic

basics

~20 s

The first exception that triggers cancellation is the one reported. Later exceptions from coroutines that were cancelled as a result are attached to it as suppressed exceptions, so they aren't lost but aren't reported separately.

solid answer

~40 s

Coroutine cancellation is **first-failure-wins**: the exception that first cancels the scope becomes the *reported* (primary) exception at the root. As that cancellation tears down the rest of the tree, other coroutines may throw too — those subsequent exceptions are **attached as suppressed exceptions** (via `Throwable.addSuppressed`) on the primary, not reported independently. Exactly one exception reaches the `CoroutineExceptionHandler`. A subtlety: `CancellationException`s that arise purely from the teardown are ignored for reporting (they're normal), but a genuinely different failure that occurs during teardown is suppressed onto the original. This avoids exception storms while still preserving the secondary failures for debugging via `getSuppressed()`. You generally cannot rely on *which* concurrent failure wins if they race; design for the set of failures, not a specific one.

code

kotlin · 16 lines
kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, e ->
        println("primary: ${e.message}")
        e.suppressed.forEach { println("suppressed: ${it.message}") }
    }
    val scope = CoroutineScope(Job() + handler)
    scope.launch {
        launch { throw IllegalStateException("first") }
        launch {
            try { delay(Long.MAX_VALUE) }
            finally { throw RuntimeException("second") } // suppressed onto first
        }
    }.join()
}

go deeper

for a junior

Recognizes that only one error is usually reported even when several things fail.

for a middle

Knows the first failure is primary and others are somehow attached.

for a senior

Explains addSuppressed/getSuppressed and that teardown CancellationExceptions are filtered.

for a principal

Reasons about non-determinism under racing failures and designs handling around failure sets and suppressed-exception inspection.

## The problem When a failure cancels a scope, it tears down many coroutines, and *several* of them may throw on the way down. If each were reported separately you'd get an exception storm and a confusing handler that fires repeatedly. Kotlin coroutines resolve this deterministically. ## First-failure-wins The **first** non-`CancellationException` that fails the scope becomes the **primary** exception — the one delivered to the `CoroutineExceptionHandler` (or thread handler) at the root. Exactly **one** exception is reported. ## Suppressed exceptions Additional exceptions thrown by other coroutines as they are cancelled are not discarded: they are **attached to the primary via `Throwable.addSuppressed(...)`**. You can later inspect them with `primary.getSuppressed()`. This is the same JVM mechanism `try`-with-resources uses. ```kotlin val handler = CoroutineExceptionHandler { _, e -> println("primary: ${e.message}") e.suppressed.forEach { println(" suppressed: ${it.message}") } } ``` ## CancellationExceptions are filtered out When the scope is being cancelled, most children just throw `CancellationException`. Those are **normal cancellation** and are *not* added as suppressed or reported — they are the expected consequence of teardown. Only genuine, *different* failures get suppressed onto the primary. This keeps the report focused on real errors. ## Racing failures If two coroutines fail at nearly the same instant, *which* one becomes primary depends on scheduling and is **not guaranteed**. Robust code should not branch on the identity of the winning exception when failures can race; instead handle the category of failure and consult `getSuppressed()` for the rest. ## Why this design - One clear primary cause → handler fires once, logs are readable. - No data loss → secondary causes preserved via suppression. - Cancellation noise filtered → teardown `CancellationException`s don't pollute the report. ## Practical implications When debugging a multi-coroutine failure, always check `getSuppressed()` — the *root cause* you see first may have several siblings hiding underneath it. And don't write logic assuming a specific exception always wins a race; treat the winner as one representative of a failure set.

  • How do you recover the secondary failures that were not reported as primary?
    Call `getSuppressed()` (or the Kotlin `suppressed` extension) on the primary throwable in your handler or catch block; the additional failures are attached there.
  • If two coroutines fail simultaneously, can you predict which is reported?
    No — the winner depends on scheduling and is not guaranteed. Design for the set of possible failures and inspect suppressed exceptions rather than relying on a specific one winning.

A pile-up on the highway: the first crash is logged as the cause; the follow-on collisions are noted in the report as related, not filed as separate accidents.

saying these in an interview costs you the question

  • Assuming every failing coroutine's exception is reported separately
  • Not knowing about addSuppressed / getSuppressed
  • Believing the winning exception in a race is deterministic
  • Thinking teardown CancellationExceptions are reported

context