Skip to content

01 · Advanced Async Patterns

Async basics and streams covered Future, async/await, and Stream. This module covers the patterns that show up once a codebase has more than one async operation happening at a time: running work concurrently, bridging callback APIs into Futures, and the sharpest of Dart's async traps — a missing await that turns a normal exception into an unhandled crash.

Running futures concurrently with Future.wait

awaiting futures one after another runs them sequentially. Future .wait starts them all immediately and waits for every one to finish — critical for anything that's actually independent (parallel API calls, parallel file reads).

Future<int> slow(int id, int ms) async {
  await Future.delayed(Duration(milliseconds: ms));
  return id;
}

Future<void> main() async {
  final sw = Stopwatch()..start();
  final results = await Future.wait([slow(1, 100), slow(2, 100), slow(3, 100)]);
  print('Future.wait results: $results in ${sw.elapsedMilliseconds}ms (concurrent)');
}
// Future.wait results: [1, 2, 3] in 105ms (concurrent)

Three 100ms delays finish in ~105ms, not ~300ms — they ran side by side. Results come back in the same order the futures were listed, regardless of which one actually finished first.

Bridging callback APIs with Completer

Some APIs (timers, native callbacks, older event-based libraries) hand you a value through a callback instead of a Future. Completer<T> lets you wrap one in a Future so it composes with the rest of your async code.

import 'dart:async';

Future<void> main() async {
  final completer = Completer<String>();
  Timer(const Duration(milliseconds: 50), () => completer.complete('done via Completer'));
  print(await completer.future);
}
// done via Completer

Call .complete() (or .completeError()) exactly once — a second call on an already-completed Completer throws Bad state: Future already completed. This is a common bug in code paths with more than one possible "finish" branch (e.g. both a success callback and a timeout firing).

eagerError: when one failure should stop everything

By default, Future.wait completes with the first error it sees, without waiting for the rest — eagerError only changes what happens to errors after that point.

Future<int> maybeFail(int id, bool fail) async {
  if (fail) throw Exception('failure $id');
  return id;
}

Future<void> main() async {
  try {
    await Future.wait([maybeFail(1, false), maybeFail(2, true), maybeFail(3, false)]);
  } catch (e) {
    print('Future.wait (default) stops at first error: $e');
  }
}
// Future.wait (default) stops at first error: Exception: failure 2

Either way, Future.wait internally keeps listening to every future you gave it (so a later failure among the others never becomes an unhandled error behind your back) — the eagerError flag only controls whether your await returns the instant the first error shows up, or waits for all of them to settle before reporting.

The trap: a missing await turns a caught exception into a crash

This is the single most dangerous async mistake in Dart. An async function that throws behaves completely differently depending on whether its caller awaits it.

Future<void> risky() async {
  await Future.delayed(const Duration(milliseconds: 10));
  throw Exception('boom from risky');
}

Future<void> main() async {
  try {
    risky(); // missing await!
    print('main: called risky (no await), moving on');
  } catch (e) {
    print('this never runs: $e');
  }

  await Future.delayed(const Duration(milliseconds: 50));
  print('main: done');
}
main: called risky (no await), moving on
Unhandled exception:
Exception: boom from risky
#0      risky (...)

The try/catch around risky() does nothing, because risky() returns immediately with a pending Future — the function's body (and its eventual throw) runs later, completely outside that try block's stack frame. By the time the exception actually happens, there's no catcher listening, so it crashes the isolate instead. The fix is always await risky(); — and this is exactly the class of bug the unawaited_futures and discarded_futures lints (covered in code quality) exist to catch automatically, because it's easy to miss in review.

Cheat sheet

Concept Meaning
Future.wait([...]) Run futures concurrently, wait for all, results in input order
Completer<T> Bridge a callback-based API into a Future
.complete() / .completeError() Call exactly once per Completer — a second call throws
eagerError: true (default) await Future.wait(...) returns as soon as the first error appears
eagerError: false Waits for every future to settle before reporting an error
Missing await on an async call Its exceptions become unhandled — the surrounding try/catch never sees them
unawaited_futures lint Flags exactly this missing-await mistake at analysis time

How It Actually Works

Completer is the primitive that bridges callback-based APIs into Future-based ones because a Future on its own has no public "complete me" method — Future's constructors only let you wrap already- determined values or existing futures. Completer exposes the .complete()/.completeError() methods that a Future deliberately hides, and internally a Completer's .future getter returns a Future object wired to that same completer's internal state — when a legacy callback fires, calling completer.complete(value) transitions that Future from pending to completed, which schedules every registered .then()/awaiter as a microtask. This is the exact mechanism async/ await itself is built on beneath the compiler-generated state machine.

eagerError changes Future.wait's internal bookkeeping about when to settle its own returned Future: by default, Future.wait waits for every input future to complete (success or failure) before resolving, collecting all errors' worth of information as far as tracking goes; with eagerError: true, it resolves (with an error) as soon as the first input future fails, without waiting for the rest — but critically, the other futures are still running to completion in the background (Dart has no way to "cancel" a Future mid-flight), they're just no longer being awaited by that particular Future.wait call, so any errors they later throw become unhandled unless something else is still listening.

The missing-await trap is a direct consequence of async functions returning a Future immediately upon hitting their first await (or immediately, if there's no await before an early return) — if you call an async function without awaiting or otherwise handling its returned Future, any exception inside it completes that Future as an error, and since nothing is listening for it, it becomes an unhandled async error, reported to the current Zone's uncaught-error handler (which, unless customized, terminates an isolate's main() or crashes a Flutter app) — this looks like "throwing past a try/catch" but is really "the try/catch never got attached to the future that actually failed."

Exercise

Write a function Future<List<String>> fetchAll(List<Future<String> Function()> tasks) that runs all tasks concurrently with Future.wait and returns their results. Then write a second version, fetchAllTolerant, that instead runs each task individually, catches any error per-task, and returns a List<String> where failed tasks are replaced with 'ERROR: $message' rather than aborting the whole batch. Demonstrate both with a mix of succeeding and failing tasks (Future.delayed + a conditional throw), and write one deliberately-broken version of fetchAll that forgets await on one of the tasks to see the unhandled-exception crash for yourself, then fix it.