skip to content

Exception Handling

How failures travel through the Job tree, why async behaves differently from launch, and where a CoroutineExceptionHandler is actually consulted. Exception handling is the part of coroutines people most often get subtly wrong.

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

explore

questions

20

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

What is CoroutineExceptionHandler in Kotlin coroutines, and what kind of failures does it handle?

level: juniorimportance: must knowfreq 70%

basics

~10 s

It is a piece of coroutine context that acts as a last-resort catcher. When a coroutine started with launch fails and nobody else handles the exception, this handler runs instead of crashing silently.

open as a page

In Kotlin coroutines, what happens when a coroutine started with `launch` throws an uncaught exception?

level: juniorimportance: must knowfreq 70%

basics

~10 s

The coroutine fails. The error travels up to its parent, which cancels itself and its other child coroutines, and then the error is reported.

open as a page

What is the difference between a regular Job and a SupervisorJob when one of their child coroutines fails?

level: juniorimportance: must knowfreq 70%

basics

~10 s

With a normal Job, if one child crashes, the parent and all the other children are cancelled too. With a SupervisorJob, a crashing child only affects itself; its siblings keep running.

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

Why does a CoroutineExceptionHandler placed on a child launch have no effect, while the same handler on the scope works?

level: middleimportance: must knowfreq 60%

basics

~20 s

Child coroutines do not handle their own failures; they pass them up to the parent. So a handler set on a child is ignored. Only the handler on the root coroutine or scope, where failures finally land, actually runs.

open as a page

How is `CancellationException` treated differently from other exceptions in coroutine propagation?

level: middleimportance: must knowfreq 65%

basics

~10 s

CancellationException means a coroutine was cancelled normally. It does not fail the parent or cancel siblings. Any other exception is a real failure and does propagate up.

open as a page

Show why a CoroutineExceptionHandler does not catch an exception from async, and where that exception must be handled instead.

level: seniorimportance: must knowfreq 50%

basics

~20 s

async stores its failure inside the returned Deferred. The handler only catches failures that nobody is expected to observe. Since you are expected to call await on a Deferred, the exception is rethrown there, and you handle it with try/catch around 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

When should you use a CoroutineExceptionHandler versus a try/catch inside the coroutine, and what can each NOT do?

level: middleimportance: should knowfreq 55%

basics

~20 s

Use try/catch to handle a known error where it happens and keep going. Use the handler as a global safety net for unexpected failures of fire-and-forget work. The handler cannot recover the coroutine; try/catch cannot catch failures from sibling coroutines.

open as a page

Given two children launched in the same scope, if one throws, what happens to the other? Walk through the Job-tree mechanics.

level: middleimportance: should knowfreq 55%

basics

~10 s

If one child fails with a real exception, the shared parent is cancelled, and the parent then cancels the other child too. So the sibling stops running.

open as a page

How does async behave for exceptions under a SupervisorJob, and how is that different from launch?

level: middleimportance: should knowfreq 40%

basics

~20 s

Under a supervisor, a failing async doesn't cancel its siblings, and it doesn't crash anything on its own — the error waits inside the Deferred until you call await(). With launch, the error surfaces immediately to a handler instead.

open as a page

When would you use supervisorScope { } versus constructing a CoroutineScope with SupervisorJob(), and how do they differ in lifecycle?

level: middleimportance: should knowfreq 55%

basics

~20 s

Use supervisorScope { } for a temporary block of parallel tasks that should not cancel each other. Use a CoroutineScope built with SupervisorJob() for a long-lived owner, like a screen or service, that you cancel later.

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

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

level: seniorimportance: should knowfreq 35%

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.

open as a page

Trace exactly where an uncaught exception ends up when it propagates to the root of a regular (non-supervisor) coroutine scope, and why it cannot be caught at the call site.

level: seniorimportance: should knowfreq 45%

basics

~20 s

The exception bubbles up the parent chain to the top Job. There it is reported — handed to an exception handler if one exists, otherwise to the platform's default handler. It cannot be caught around the launch call because the body runs later.

open as a page

Under a SupervisorJob, where must a CoroutineExceptionHandler be installed to actually catch a failing child, and why?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Install the handler in the context of the failing coroutine itself (or its supervisor scope), not on a nested child. Under a SupervisorJob each child is treated as a top-level coroutine, so its own handler is the one that runs.

open as a page

A developer writes launch(SupervisorJob()) { ... } inside a scope and expects failure isolation. Why is this a bug, and what actually happens?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Passing a SupervisorJob into launch makes it the coroutine's parent job, which breaks the link to the surrounding scope. The work escapes structured concurrency: the scope no longer waits for it or cancels it, and supervision doesn't apply the way they expect.

open as a page

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%

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.

open as a page