skip to content

In a Dart server job, an unawaited Future fails after its surrounding try/catch has already finished — where does that error go?

level: seniorimportance: must knowfreq 45%

answer

  1. try/catch only covers awaited work
  2. no listener when the future fails
  3. reported to the future's own zone
  4. root zone: process dies
  5. error zone: onError, job looks green

basics

~20 s

A failed Future with no listener is an uncaught asynchronous error: it goes to the handleUncaughtError of the zone the future belongs to. In the root zone of a standalone Dart program that kills the process; inside runZonedGuarded it reaches onError instead.

solid answer

~50 s

A `try`/`catch` only sees errors from futures you `await` inside it. An unawaited `Future` that fails later has no listener, so Dart reports it as an **uncaught asynchronous error** to the `handleUncaughtError` of the zone the future was created in — not to the caller's `catch`. In the root zone of a standalone Dart VM program that rethrows at the top level: the error is printed as an unhandled exception and the process dies. If the job runs inside `runZonedGuarded`, the zone's `onError` receives it instead and the job carries on — which is how a job ends up reporting success while its upload failed. The fix is to `await` the work (or collect it with `Future.wait`) so the error flows back to the `catch`, and to keep a zone handler only as a last-resort logger.

code

dart · 23 lines
dart
import 'dart:async';

Future<void> uploadReport() async {
  await Future<void>.delayed(const Duration(milliseconds: 10));
  throw StateError('storage unavailable');
}

Future<void> runJob() async {
  try {
    unawaited(uploadReport());
    print('job finished');
  } catch (e) {
    print('never reached: $e');
  }
}

void main() {
  runZonedGuarded(runJob, (error, stackTrace) {
    print('zone caught: $error');
  });
  // Prints: job finished
  //         zone caught: Bad state: storage unavailable
}

go deeper

for a junior

Remember that try/catch only catches errors from futures you await inside it; an unawaited failure escapes it.

for a middle

Explain that a failed future with no listener becomes an uncaught error reported to its zone, and what the root zone does with it on the Dart VM.

for a senior

Diagnose a green job with a silent failure: find the unawaited or late-awaited future, route it back with await or Future.wait, and keep a zone handler as a safety net that fails the job.

for a principal

Set a team rule for fire-and-forget work: every detached future gets an explicit handler, and entry points own a last-resort handler that turns stray errors into a failing exit.

## The scenario A nightly report job builds a file, starts `uploadReport()`, and deliberately does not wait for it, so the job can move on: ```dart try { unawaited(uploadReport()); log('job finished'); } catch (e) { log('job failed: $e'); } ``` The upload fails a second later. `job failed` is never logged and the job exits with success, yet the report never arrives. ## Why the catch never runs - `try`/`catch` is **synchronous scoping**. It catches exceptions thrown while control is inside the block, plus errors from futures you `await` inside it (because `await` resumes the function *inside* the block and throws there). - `unawaited(future)` only silences the `unawaited_futures` lint. It attaches no error handler. - By the time `uploadReport()` fails, the `try` block has long finished. The future completes with an error and **has no listener**. ## Where the error goes When a `Future` completes with an error and nobody is listening, the SDK calls `handleUncaughtError` on **the zone the future belongs to** — the zone that was current when it was created — not on whichever code might later look at it. What happens next depends on that zone: | Zone | Result | |---|---| | **Root zone** of a standalone Dart VM program | The root handler rethrows at the top level; the error is printed as an unhandled exception and the process terminates | | A zone created by **`runZonedGuarded`** | The `onError(error, stackTrace)` callback runs; the rest of the program keeps going | | A zone with a custom **`ZoneSpecification.handleUncaughtError`** | That handler decides: log, rethrow to the parent, or ignore | | A Flutter app | Flutter installs its own reporting; its hooks are a separate topic | So the same bug is either a loud crash or a silent log line, depending on whether some framework or `main` wrapped the job in an error zone. Server frameworks and job runners commonly do, which is why the error seems to **disappear**: it went to a handler that logged it and moved on. ## A second trap: failing before anyone listens The rule is about listeners *at the moment of failure*. This code also reports an uncaught error even though it eventually awaits: 1. `final upload = uploadReport();` 2. `await buildIndex();` — meanwhile `upload` fails with no listener attached; 3. `await upload;` — too late; the error was already reported as uncaught. Attach the handler before yielding: `await Future.wait([uploadReport(), buildIndex()])`, or start the future right where you await it. ## Fixes, in order of preference - **Await it.** If the job's result depends on the upload, `await uploadReport();` inside the `try`. - **Collect concurrent work.** `await Future.wait([...])` keeps concurrency and still routes the first error back to the caller. - **If it really is fire-and-forget**, give it its own handler: `uploadReport().catchError(...)`, so the failure is logged with context instead of vanishing. - **Keep a zone handler as the safety net**, not the plan: wrap the job's entry point in `runZonedGuarded` so anything still uncaught is logged and the job exits non-zero rather than looking green. ## How to spot it in review - An `unawaited(...)` or a bare call to an `async` function whose result is dropped. - A future stored in a variable and awaited only after other `await`s. - A job that reports success while an `onError` log contains a stack trace from the same run.

  • Why does storing a future and awaiting it a few lines later still produce an uncaught error?
    An error is reported as uncaught if the future completes with it while no listener is attached. If other `await`s run first, the stored future can fail during them, before your `await` registers a handler. Start and await together, or combine with `Future.wait` so a handler is attached immediately.
  • Which zone receives the error if the future was created inside runZonedGuarded but awaited outside it?
    The guarded zone. Errors are reported to the zone the failing future belongs to, and future errors never cross an error-zone boundary. The outside `await` never completes, while the guarded zone's `onError` receives the error.

saying these in an interview costs you the question

  • Wrapping unawaited(...) in try/catch catches the future's later error.
  • unawaited() attaches an error handler that swallows failures.
  • An uncaught async error always crashes any Dart program.
  • The error goes to the zone of whoever awaits the future later.
  • Awaiting a stored future later always prevents an uncaught error.