skip to content

async Exception Deferral

async stores its exception in the Deferred and only rethrows it when you await, so an un-awaited async can hide a failure. The nuance is that a root async still cancels its scope through structured concurrency.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

When you call async { ... } and the block throws an exception, at what point is that exception observed by the caller?

level: juniorimportance: must knowfreq 70%

answer

  1. async returns Deferred
  2. exception stored, thrown at await()
  3. async line itself never throws
  4. launch reports eagerly, async defers
  5. deferral != suppression

basics

~10 s

The exception is stored inside the Deferred result and is re-thrown when you call await() on it. Calling async itself does not throw.

solid answer

~40 s

async returns a Deferred<T>. If the coroutine body fails, async catches the exception, completes the Deferred exceptionally, and re-throws it only when you call await(). So `val d = async { error("boom") }` does NOT throw at the async line; `d.await()` throws. This is called exception deferral: async defers the exception into its result holder. Contrast with launch, which has no result to await and reports failures immediately to the parent/CoroutineExceptionHandler. Note that even though await() is where you SEE the exception, the failure of a root async still cancels its parent scope through structured concurrency independently of whether you ever call await().

code

kotlin · 11 lines
kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferred = async { throw IllegalStateException("boom") }
    println("async returned, no throw yet")
    try {
        deferred.await()
    } catch (e: IllegalStateException) {
        println("caught at await: ${e.message}")
    }
}

go deeper

for a junior

Knows async returns Deferred and the exception surfaces at await(), not at the async line.

for a middle

Explains the catch-store-rethrow mechanism and contrasts it with launch's eager reporting.

for a senior

Distinguishes the throwing site (await) from propagation/cancellation through structured concurrency.

for a principal

Frames it as the future/promise consumption-site model and reasons about API design trade-offs between async and launch.

## What `async` returns `async` is a coroutine builder that returns a `Deferred<T>` — a `Job` that also carries a future result of type `T`. You retrieve that result with the suspending function `await()`. ## Exception deferral When the body of `async` throws, the builder does not propagate the exception immediately. Instead it: 1. Catches the exception. 2. Completes the `Deferred` in a *failed* state, storing the exception. 3. Re-throws the stored exception **only when `await()` is called**. This is why this code prints nothing from the `async` line itself: ```kotlin val deferred = scope.async { throw IllegalStateException("boom") } // no throw here val result = deferred.await() // IllegalStateException thrown HERE ``` ## Why it works this way `async` models a *value-producing* computation. The natural place to surface a failure is where the value is consumed — `await()`. This mirrors futures/promises in other languages. By contrast `launch` is *fire-and-forget*: it returns a plain `Job` with no value, so it cannot defer anything and reports failures to the parent immediately. ## The subtlety: deferral is not suppression Deferral only controls *where you observe* the exception. It does NOT mean the failure is hidden. If the `async` is a direct child of a regular `coroutineScope`/`Job`-based scope, the failure still propagates upward through structured concurrency and cancels siblings, **even if you never call `await()`**. The deferral via `await()` is about the *throwing site for the caller*, not about whether the parent learns of the failure. Key APIs: `async`, `Deferred<T>`, `await()`, `Job`, `coroutineScope`.

  • Does launch behave the same way?
    No. launch returns a Job with no result and no await(), so it reports failures immediately to the parent/CoroutineExceptionHandler rather than deferring them.
  • If I never call await(), is the exception gone?
    Not necessarily. The Deferred stores it, and if the async is a child of a normal scope the failure still propagates and cancels the scope through structured concurrency.

Like a sealed envelope marked 'bad news inside' — you only get hit by the news when you open it (await), but the post office (scope) already knows it was sent.

saying these in an interview costs you the question

  • Saying the exception is thrown at the `async { }` call site
  • Claiming async swallows the exception entirely
  • Confusing async with launch's eager reporting
  • Thinking await() must be called for the parent to be cancelled

context

open as a page

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%

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.

open as a page

Contrast how async and launch handle a thrown exception. Why does CoroutineExceptionHandler work for launch but not for a root async's deferred exception?

level: middleimportance: should knowfreq 50%

basics

~10 s

launch 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.

open as a page

You launch several async tasks where one fails fast. Walk through the timing of exceptions with await() in sequence versus awaitAll(), and how deferral plus scope cancellation interact.

level: seniorimportance: should knowfreq 40%

basics

~20 s

awaitAll() throws as soon as ANY Deferred fails, not in list order. Awaiting one-by-one throws when you reach a failed one. And under a normal scope, a failed async cancels the others before you even await them.

open as a page

Why can an exception thrown inside async be 'lost' or hard to diagnose, and what concrete patterns prevent silent failures from deferred Deferreds?

level: seniorimportance: should knowfreq 30%

basics

~20 s

If an async runs under a SupervisorJob and you never call await(), its stored exception is never observed and effectively vanishes. Always await every async, or use launch when you do not need a result.

open as a page