skip to content

In Dart, what does runZonedGuarded do, and why can a Future that fails inside it never complete when awaited from outside?

level: seniorimportance: should knowfreq 30%

answer

  1. forks a new error zone
  2. onError for async and sync errors
  3. returns null on a sync throw
  4. zone keeps running after errors
  5. future errors never cross error zones

basics

~20 s

runZonedGuarded runs its body in a new error zone whose onError receives uncaught asynchronous errors and synchronous throws. Future errors never cross error-zone boundaries, so a future failing inside it goes to onError and never completes for an outside awaiter.

solid answer

~40 s

`runZonedGuarded(body, onError, {zoneValues, zoneSpecification})` forks a child of `Zone.current` whose `handleUncaughtError` calls `onError(error, stackTrace)` in the parent zone, then runs `body` there. It also catches a synchronous throw from `body`: `onError` runs and the call returns `null`, which is why its return type is `R?`. The zone keeps running after an error, so `onError` may fire many times. Any zone with such a handler is an **error zone**, and future errors **never cross error-zone boundaries**: if a future created inside the guarded zone fails and code outside awaits it, the error is delivered to the guarded zone's `onError` and the outside `await` never completes. `runZoned`'s old `onError` parameter is deprecated in favour of `runZonedGuarded`.

code

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

Future<void> main() async {
  final task = runZonedGuarded(
    () => Future<void>.delayed(
      const Duration(milliseconds: 10),
      () => throw StateError('disk full'),
    ),
    (error, stackTrace) => print('guarded zone: $error'),
  )!;

  final outcome = await task
      .then((_) => 'completed')
      .timeout(const Duration(seconds: 1), onTimeout: () => 'never completed');
  print(outcome); // never completed
}

go deeper

for a junior

Know that runZonedGuarded gives a block of async code one place where its uncaught errors are delivered.

for a middle

Explain its signature: a forked zone, onError for async and sync errors, the nullable return, and that the zone keeps running after an error.

for a senior

Explain error-zone boundaries and diagnose a caller hanging on a future created inside a guarded helper; wrap entry points rather than individual returned futures.

for a principal

Decide where error zones sit in a service's architecture so every stray failure is logged once, with context, without severing futures that callers depend on.

## What a zone is A **zone** (`dart:async`'s `Zone`) is an execution context that follows asynchronous code. Every callback — a `then` handler, a `Timer` callback, a stream listener — is bound to the zone that was current when it was registered, and runs in that zone later. `Zone.root` is the outermost zone; `Zone.current` is the one running now. A zone can override behaviours such as how uncaught errors are handled. ## What runZonedGuarded does `R? runZonedGuarded<R>(R body(), void onError(Object error, StackTrace stack), {zoneValues, zoneSpecification})`: 1. Forks a new zone from `Zone.current`, installing a `handleUncaughtError` hook (merged into any `zoneSpecification` you pass). 2. Runs `body` in that zone and returns its result. 3. If `body` throws **synchronously**, calls `onError` and returns `null` — hence `R?`. 4. Later, whenever an asynchronous error in that zone goes unhandled — a failed future with no listener, a throwing `Timer` callback, a stream error with no `onError` — calls `onError` with the error and stack trace. Details worth stating: - **`onError` runs in the parent zone.** If it throws, the error goes to the parent zone's handler, not back into itself. - **The zone keeps running.** Unlike a `catch`, handling an error does not stop other scheduled callbacks in the zone, so `onError` can be called many times. - **Deprecation.** `runZoned(..., onError: ...)` still compiles but is marked `@Deprecated('Use runZonedGuarded instead')`. ## Error zones and their boundaries A zone with an uncaught-error handler is an **error zone**; `Zone.errorZone` returns the nearest one, and `Zone.inSameErrorZone(other)` compares them. The rule the SDK documents is strict: > Asynchronous errors in futures never cross zone boundaries between zones with a different error zone. Consequences: | Where the future fails | Who awaits it | Outcome | |---|---|---| | Inside the guarded zone | Code inside the same zone | Normal: the `await` throws, `catch` works | | Inside the guarded zone | Code outside it | The guarded zone's `onError` gets the error; the outside `await` **never completes** | | Outside (e.g. the root zone) | A `then` registered inside the guarded zone | The error is treated as unhandled at the boundary and reported in its own zone | The second row is the surprising one. A helper that wraps a background task in `runZonedGuarded` to "make it safe" and returns the task's future to its caller has created a future that, on failure, simply hangs for that caller. ## Using it well in a server job - Wrap the **entry point** — `main` or the job runner — so stray errors are logged with context and the process can exit non-zero. - Do not wrap individual calls whose futures you return to callers; use `try`/`catch` around `await` for those. - Keep `onError` small and non-throwing: log, count, set an exit code. - Remember that `onError` does not stop the failing work's siblings; if one failure means the job is invalid, record that and stop the job explicitly. ## Streams follow a simpler rule For streams, callbacks run in the zone where the stream is **listened to**, not where it was created. A `map` callback that throws is reported to the listening zone's handler if the subscription has no `onError`.

  • What happens if onError itself throws?
    `onError` runs in the parent zone. If it throws the same error, it is forwarded to the parent zone's uncaught-error handler; if it throws a new error, that new error is forwarded. In a root-zone program that ends the process, so keep `onError` simple and non-throwing.
  • Why is runZonedGuarded's return type the nullable R? rather than plain R?
    Because a synchronous throw from `body` is caught and passed to `onError`, after which there is no value to return. The function then returns `null`, so callers must handle a nullable result or be sure `body` cannot throw synchronously.

A sealed quarantine room with its own nurse: anything that goes wrong inside is treated by that nurse, and a patient who falls ill in there is never sent out to the relatives waiting in the corridor, who just keep waiting.

saying these in an interview costs you the question

  • runZonedGuarded stops running the zone after the first error.
  • A future failing inside runZonedGuarded throws at an outside await.
  • runZonedGuarded only handles asynchronous errors, never synchronous ones.
  • runZoned with onError is the current recommended API.
  • onError runs inside the guarded zone, so its own errors loop back to it.