Errors, exceptions and async error handling

Tell Error from Exception, catch with on and finally, and handle failure in Futures and Streams without losing the stack trace.

Throwing and catching

// Exception for expected, recoverable conditions; Error for programmer mistakes
class InsufficientStock implements Exception {
  InsufficientStock(this.sku, this.requested, this.available);
  final String sku;
  final int requested;
  final int available;

  @override
  String toString() => 'InsufficientStock($sku: want $requested, have $available)';
}

class ConfigError extends Error {
  // Error subclasses must supply a stack trace when the API needs one
  ConfigError(this.message, [this.cause]);
  final String message;
  final Object? cause;

  @override
  String toString() => 'ConfigError: $message';
}

class Service {
  void reserve(String sku, int qty) {
    final available = 2;
    if (qty <= 0) {
      throw ArgumentError.value(qty, 'qty', 'must be positive');   // a bug in the caller
    }
    if (qty > available) {
      throw InsufficientStock(sku, qty, available);                // an expected outcome
    }
  }
}

void main() {
  final svc = Service();
  try {
    svc.reserve('ABC-1', 5);
  } on InsufficientStock catch (e, stack) {
    print('$e');
    print(stack);                       // keep the trace: it is the debugging cost
  } on ArgumentError catch (e) {
    print('bad caller: ${e.message}');
  } catch (e) {
    print('unexpected: $e');
  } finally {
    print('always runs');
  }
}
  • Error and its subclasses signal a bug: a bad argument, a broken invariant, a failed cast. They should crash, not be caught and swallowed.
  • Exception is for a condition the caller may reasonably handle: a missing file, an unavailable resource, a validation failure.
  • Order on clauses from most specific to least specific; the first match wins.
  • Always bind the second catch parameter when you log or rethrow. Losing the stack trace turns a five-minute bug into an afternoon.
  • rethrow preserves the original trace; throw e; resets it to the current line.

Errors in Futures and Streams

Future<int> fetchStock(String sku) async {
  if (sku.isEmpty) throw ArgumentError('sku');
  await Future<void>.delayed(const Duration(milliseconds: 10));
  return 3;
}

Future<void> main() async {
  // try/catch works across await inside an async function
  try {
    final stock = await fetchStock('ABC-1');
    print(stock);
  } on ArgumentError catch (e, s) {
    print('rejected: $e');
    print(s);
  }

  // an unawaited Future that fails becomes an unhandled error and can kill the isolate
  // fetchStock('');                       // do not do this: the error has no handler
  unawaited(fetchStock('').catchError((Object e) => print('handled: $e')));

  // whenComplete runs on both outcomes; catchError only handles the failure
  await fetchStock('ABC-1')
      .then((v) => print('then $v'))
      .catchError((Object e) => print('error $e'))
      .whenComplete(() => print('done'));

  // a timeout does NOT cancel the underlying work
  try {
    await fetchStock('X').timeout(const Duration(milliseconds: 1));
  } on TimeoutException catch (e) {
    print('timed out after ${e.duration}');
  }

  // a Stream can fail part way through; the error arrives in the same await-for
  final stream = Stream<int>.fromIterable([1, 2, 3]).map((n) {
    if (n == 2) throw StateError('bad value');
    return n;
  });

  try {
    await for (final value in stream) {
      print('got $value');
    }
  } on StateError catch (e) {
    print('stream failed: $e');
  }
}

void unawaited(Future<void> f) {}
void unawaited2(Future<dynamic> f) {}
SituationMechanismNote
Synchronous failurethrow inside a tryCaught by the enclosing try
Async failureThe returned Future completes with an errorAwait it, or attach a handler
Unawaited failureReported to the current zoneCrashes the isolate unless a zone handles it
Stream failureThe stream emits an error eventonError callback or a catch inside await for
Cleanupfinally or whenCompleteRuns on both paths
TimeoutFuture.timeoutDoes not stop the work, only stops waiting

An async function always returns a Future, even when it throws synchronously. The error is delivered through the returned future, which is why the caller must await it or attach a handler rather than wrapping the call in a try.

Zones, guards and structured boundaries

import 'dart:async';

void main() {
  // a guarded zone is the top-level net for errors nobody handled
  runZonedGuarded(
    () async {
      // a failing fire-and-forget future is caught here, not by the isolate
      Future<void>.delayed(const Duration(milliseconds: 1))
          .then((_) => throw StateError('nobody awaited me'));

      await Future<void>.delayed(const Duration(milliseconds: 20));
      print('work finished');
    },
    (error, stack) {
      print('zone caught: $error');
      print(stack);
      // log and report here: this is the last chance before the process dies
    },
    zoneSpecification: ZoneSpecification(
      print: (self, parent, zone, line) => parent.print(zone, '[log] $line'),
    ),
  );
}

// a retry helper built on Futures, without a loop of awaits
Future<T> retry<T>(
  Future<T> Function() action, {
  int attempts = 3,
  Duration delay = const Duration(milliseconds: 200),
}) async {
  var lastError = StateError('no attempt made');
  for (var i = 0; i < attempts; i++) {
    try {
      return await action();
    } catch (e) {
      lastError = e is StateError ? e : StateError(e.toString());
      if (i < attempts - 1) await Future<void>.delayed(delay * (i + 1));  // backoff
    }
  }
  throw lastError;
}
⚠️
Never leave a Future without a handler. An unhandled asynchronous error is reported to the zone, and without a guarded zone the isolate terminates. Either await it, or attach catchError, or explicitly ignore it with an assigned unawaited(...) so the intent is visible.

FAQ

When should I throw an Error rather than an Exception?
When the caller violated a precondition that cannot be recovered from: a null where non-null is required, an invalid argument, a failed cast. If the caller could reasonably do something about it, define an Exception.
Does try/finally work with await?
Yes. finally runs when the awaited future completes, including when it completes with an error. whenComplete on a Future is the same idea in a chain.

Classes, futures and streams Files, JSON and HTTP in daily Dart

Last refreshed 2026-09-18.