skip to content

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%

answer

  1. supervised + unawaited async = lost exception
  2. async exceptions not sent to CoroutineExceptionHandler
  3. prefer launch when no result needed
  4. always awaitAll what you start
  5. don't swallow CancellationException around await

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.

solid answer

~40 s

async stores its failure in the Deferred and only re-throws it at await(). Under a normal scope, structured concurrency still propagates the failure even without await(). But under a SupervisorJob/supervisorScope, the failure is isolated to that Deferred — if you never await it, the exception is silently dropped (it is NOT routed to CoroutineExceptionHandler, because async exceptions are considered handle-on-demand). Patterns to avoid this: (1) prefer launch when you do not need a return value; (2) always await every async you start, ideally with awaitAll; (3) wrap await() in try/catch or runCatching to handle the failure explicitly; (4) avoid creating async under a SupervisorJob unless you genuinely await it. Lints and code review should flag an async whose Deferred is never awaited.

code

kotlin · 12 lines
kotlin
import kotlinx.coroutines.*

suspend fun safeFanOut(items: List<Int>) = supervisorScope {
    val deferreds = items.map { i -> async { process(i) } }
    // Always observe each result; isolate failures explicitly
    deferreds.map { d ->
        runCatching { d.await() }
            .onFailure { e -> if (e is CancellationException) throw e }
    }
}

suspend fun process(i: Int): Int { if (i < 0) error("bad $i"); return i * 2 }

go deeper

for a junior

Understands you should await every async and prefer launch when no value is needed.

for a middle

Explains that supervised + unawaited async drops the exception and that it is not sent to the handler.

for a senior

Builds safe fan-out with runCatching+await while preserving CancellationException, and reasons about diagnosability.

for a principal

Establishes team conventions/lints against unawaited Deferreds and chooses scope topology to avoid silent loss.

## The silent-failure trap `async` defers its exception into the `Deferred`. Two cases: - **Normal scope (Job parent):** even without `await()`, the failing child cancels the parent — the error is visible (the program/scope dies). Not silent, though possibly confusing. - **Supervised scope (`SupervisorJob`/`supervisorScope`):** the failure is *isolated* to that Deferred. The library does NOT report a supervised async's exception to `CoroutineExceptionHandler`, because it expects you to observe it at `await()`. If you never call `await()`, the exception is **dropped silently**. ```kotlin import kotlinx.coroutines.* fun main() = runBlocking { val scope = CoroutineScope(SupervisorJob()) scope.async { throw IllegalStateException("never seen") } delay(100) // exception is lost: no await, supervised -> not reported println("finished cleanly, bug hidden") } ``` ## Why it is hard to diagnose The stack trace points at `await()` (the throw site), not necessarily at the failing operation's original frame — though kotlinx-coroutines stack-trace recovery helps. And if there is no `await()`, there is no trace at all. ## Patterns that prevent silent loss 1. **Use `launch` when you do not need a result.** A `launch` failure is uncaught and reaches the `CoroutineExceptionHandler` (or cancels the parent), so it cannot be silently dropped the same way. 2. **Always await every async.** If you start it, await it — ideally via `awaitAll(...)` so none is forgotten. 3. **Wrap `await()` explicitly:** `runCatching { d.await() }` or `try { d.await() } catch (e: Exception) { ... }`. 4. **Be deliberate about supervisor scopes.** Only create `async` under a `SupervisorJob` when you will await it; otherwise a normal scope at least surfaces the failure. 5. **Code-review/lint rule:** flag any `Deferred` that is created but never awaited. ## Cancellation caveat When catching around `await()`, do not swallow `CancellationException` — re-throw it so structured cancellation still works. Catch specific exceptions or re-check `coroutineContext.isActive`. Key APIs: `async`, `Deferred.await()`, `awaitAll`, `launch`, `SupervisorJob`, `supervisorScope`, `CoroutineExceptionHandler`, `runCatching`, `CancellationException`.

  • Why is a supervised async's exception not delivered to CoroutineExceptionHandler?
    Because async exceptions are treated as handle-on-demand via await(); the library assumes the consumer will observe them, so it does not report them as uncaught.
  • What should you NOT swallow when wrapping await() in try/catch?
    CancellationException — re-throw it, otherwise you break cooperative cancellation and structured concurrency.

saying these in an interview costs you the question

  • Believing async failures always reach CoroutineExceptionHandler
  • Starting async under a SupervisorJob and never awaiting it
  • Catching Throwable around await() and swallowing CancellationException
  • Using async purely for side effects instead of launch

context