skip to content

Explain why a failing root async can cancel its entire scope even though its exception is only thrown at await(). How do deferral and structured concurrency interact here?

level: middleimportance: must knowfreq 60%

answer

  1. two mechanisms: await-site vs scope propagation
  2. root async = child of normal Job
  3. child failure cancels parent + siblings
  4. supervisorScope isolates the failure
  5. deferral is about the throw site, not propagation

basics

~20 s

await() decides where YOU see the error. But the async is also a child coroutine, so when it fails it tells its parent Job, which cancels the parent and all siblings — regardless of whether you await.

solid answer

~40 s

Two independent things happen when an async fails. (1) Deferral: the exception is stored in the Deferred and re-thrown at await() — this is purely about the caller's throwing site. (2) Structured concurrency: the async is a child of a parent Job. A failing child cancels its parent (unless the parent is a SupervisorJob), and the parent cancels all other children. This second mechanism runs the moment the async fails, independent of await(). So a 'root' async (direct child of a non-supervisor scope like coroutineScope) that throws will cancel the whole scope even if you never call await(). To get the value-style isolation people expect, you must run the async under a supervisorScope/SupervisorJob, where a child failure does not cancel the parent.

code

kotlin · 14 lines
kotlin
import kotlinx.coroutines.*

// Root async under a normal scope cancels everything
suspend fun rootCancels() = coroutineScope {
    async { throw IllegalStateException("boom") } // never awaited
    async { delay(10_000) }.await() // dies via scope cancellation
}

// Under supervisorScope, failure stays deferred to await()
suspend fun isolated() = supervisorScope {
    val d = async { throw IllegalStateException("boom") }
    val ok = async { 1 }.await() // survives
    runCatching { d.await() }.exceptionOrNull() // only seen here
}

go deeper

for a junior

Recognizes that not calling await() does not make the program safe and that a scope can still be cancelled.

for a middle

Cleanly separates the await() throwing site from structured-concurrency cancellation and names supervisorScope as the isolation tool.

for a senior

Reasons about which exception propagates, when SupervisorJob applies, and the dropped-exception risk when not awaiting.

for a principal

Designs scope topology (supervisor vs job) deliberately to match desired fault isolation across a service.

## Two orthogonal mechanisms When `async` fails, two things are in play and they are **independent**: ### 1. Exception deferral (the `await()` site) The `Deferred` stores the exception and re-throws it when you call `await()`. This determines *where the caller's code sees the throw*. It says nothing about the parent. ### 2. Structured concurrency propagation (the scope) Every coroutine builder attaches the new coroutine as a **child** of the surrounding `Job` (from the `CoroutineScope`'s context). When a child fails: - With a normal `Job` parent: the child **cancels the parent**, which then **cancels all sibling children** and propagates the exception up. - With a `SupervisorJob`/`supervisorScope` parent: a child failure is **isolated** — it does not cancel the parent or siblings. ## Why a root async cancels its scope 'Root async' = an `async` that is a direct child of a regular (non-supervisor) scope: ```kotlin import kotlinx.coroutines.* suspend fun demo() = coroutineScope { val d = async { throw IllegalStateException("boom") } val other = async { delay(10_000); 42 } // We NEVER call d.await() other.await() // still fails: the scope was cancelled by d's failure } ``` Here `d` is a child of the `coroutineScope`'s Job. The moment `d` throws, structured concurrency cancels the `coroutineScope`, which cancels `other`. The CancellationException from the scope surfaces, and the original `IllegalStateException` propagates out of `coroutineScope`. The deferral via `await()` is irrelevant to *this* path. ## The fix for isolation If you want the failure to be observable *only* at `await()` and NOT cancel the scope, the async must not be a directly-propagating root child: ```kotlin supervisorScope { val d = async { throw IllegalStateException("boom") } // scope is NOT cancelled; failure surfaces only at d.await() } ``` Under `supervisorScope`/`SupervisorJob`, a failing `async` keeps its exception purely deferred — you only see it at `await()`, and siblings live on. Key APIs: `async`, `Deferred.await()`, `coroutineScope`, `supervisorScope`, `Job`, `SupervisorJob`.

  • Under supervisorScope, does the exception still need to be observed?
    Yes — for a supervised async the exception is held in the Deferred and surfaces at await(); if you never await, it is effectively dropped (not reported to the parent), which can hide bugs.
  • Does the original exception or a CancellationException propagate out of the failing scope?
    The original exception propagates out of coroutineScope; siblings are cancelled with a CancellationException, but the root cause is the original exception.

saying these in an interview costs you the question

  • Claiming the scope is only cancelled if you call await()
  • Believing async never cancels its parent because it 'defers'
  • Not knowing supervisorScope changes the propagation behavior
  • Thinking await() and structured-concurrency cancellation are the same mechanism

context