Emmanuel Akinfulubi

Asynchrony and isolates all 6 written

Futures, streams, cancellation, backpressure and real concurrency. Where most production bugs live.

8 Futures, and the event loop underneath

A Dart isolate runs one thing at a time. Asynchrony is not threads, it is a queue: your code runs to a stopping point, then the loop picks up the next piece of work. There are two queues, and knowing which one a callback lands on is the difference between predicting your program and guessing at it.

Two queues, drained in a fixed order

  • Synchronous code runs first, all of it, to the end of the current turn. Nothing interrupts it.
  • The microtask queue is drained next, completely. then callbacks and scheduleMicrotask land here.
  • The event queue gets one item per turn: timers, IO, gestures, Future.delayed, and a plain Future(...).

So a microtask cannot be starved by a timer, and a timer can be starved by microtasks. A microtask that schedules another microtask will loop forever and your app will never paint again.

Five schedulings, one predictable order

ran and analysed
import 'dart:async';

Future<void> main() async {
  final log = <String>[];

  log.add('1 sync, first line');

  // Both of these go on the EVENT queue, in the order they were created.
  Future(() => log.add('5 event queue, a plain Future'));
  Future.delayed(Duration.zero, () => log.add('6 event queue, delayed by zero'));

  // Both of these go on the MICROTASK queue, which is drained first.
  Future.value(0).then((_) => log.add('3 microtask, then on a finished future'));
  scheduleMicrotask(() => log.add('4 microtask, scheduled directly'));

  log.add('2 sync, last line');

  // Nothing above has run yet except the two synchronous lines.
  final endOfTurn = List<String>.of(log);

  // Ten milliseconds of event queue is plenty for all four callbacks.
  await Future<void>.delayed(const Duration(milliseconds: 10));

  for (final line in log) {
    print(line);
  }

  test('every synchronous line ran before any callback', endOfTurn,
      ['1 sync, first line', '2 sync, last line']);
  test('then the microtask queue, in the order it was filled', log.sublist(2, 4),
      ['3 microtask, then on a finished future', '4 microtask, scheduled directly']);
  test('and only then the event queue', log.sublist(4),
      ['5 event queue, a plain Future', '6 event queue, delayed by zero']);
  test('so the order is not the order it was written', log.first, '1 sync, first line');
}

What it printed, when the build ran it

1 sync, first line
2 sync, last line
3 microtask, then on a finished future
4 microtask, scheduled directly
5 event queue, a plain Future
6 event queue, delayed by zero
ok   every synchronous line ran before any callback
ok   then the microtask queue, in the order it was filled
ok   and only then the event queue
ok   so the order is not the order it was written

Read the numbering against the source order. The two event-queue callbacks were written first and ran last. That is the whole idea.

Duration.zero does not mean "now". It means "put this on the event queue", and the event queue is behind every microtask that exists. If you have ever fixed a bug by wrapping something in Future.delayed(Duration.zero, ...), what you actually did was move it after the current frame's work — which is a real fix, but only if you can say why.

An async function starts running immediately

This one catches people out. Marking a function async does not defer it. The body runs synchronously, on the caller's turn, right up to the first await. Only then does control go back to the caller.

The body runs now. The await is where it stops.

ran and analysed
final log = <String>[];

/// The first line of this body runs before load() has even returned.
Future<int> load(String tag) async {
  log.add('$tag: body starts, synchronously');
  await Future<void>.delayed(const Duration(milliseconds: 5));
  log.add('$tag: body resumes, later');
  return tag.length;
}

Future<void> main() async {
  final pending = load('first');           // the body has already run its first line
  final afterTheCall = List<String>.of(log);

  log.add('caller carries on');
  final n = await pending;
  log.add('caller has the value $n');

  for (final line in log) {
    print(line);
  }

  test('calling it ran the body as far as the first await', afterTheCall,
      ['first: body starts, synchronously']);
  test('then the caller carried on without waiting', log[1], 'caller carries on');
  test('and the body resumed after that', log[2], 'first: body resumes, later');
  test('the value only arrived at the await', n, 5);
}

What it printed, when the build ran it

first: body starts, synchronously
caller carries on
first: body resumes, later
caller has the value 5
ok   calling it ran the body as far as the first await
ok   then the caller carried on without waiting
ok   and the body resumed after that
ok   the value only arrived at the await

Two things follow. Cheap validation at the top of an async function really does happen straight away, so putting it there is worth doing. And a throw before the first await still does not come back up the call stack — it goes into the returned Future, which is lesson 9's problem.

One after another, or all at once

Three awaits in a row means three round trips end to end. The fix is not a library, it is noticing that the futures can be created before any of them is awaited. Future.wait does exactly that, and the timings below are from the real Stopwatch in the code.

Sequential versus overlapping, timed for real

ran and analysed
Future<int> fetch(int ms) async {
  await Future<void>.delayed(Duration(milliseconds: ms));
  return ms;
}

Future<void> main() async {
  // Each await stops the line before the next request is even made.
  final oneByOne = Stopwatch()..start();
  final a = await fetch(100);
  final b = await fetch(100);
  final c = await fetch(100);
  oneByOne.stop();

  // The list is built first, so all three are already in flight at the await.
  final together = Stopwatch()..start();
  final all = await Future.wait([fetch(100), fetch(100), fetch(100)]);
  together.stop();

  // And the answers come back in LIST order, not in the order they finished.
  final mixed = await Future.wait([fetch(90), fetch(10), fetch(50)]);

  print('one after another  ${oneByOne.elapsedMilliseconds} ms');
  print('overlapping        ${together.elapsedMilliseconds} ms');
  print('finish order was   10, 50, 90 — but the answer is $mixed');

  test('the results are the same either way', [a, b, c], all);
  test('one after another costs three delays', oneByOne.elapsedMilliseconds >= 300, true);
  test('overlapping costs about one', together.elapsedMilliseconds < 200, true);
  test('so it really was faster, not just tidier',
      oneByOne.elapsedMilliseconds > together.elapsedMilliseconds * 2, true);
  test('and Future.wait answers in list order', mixed, [90, 10, 50]);
}

What it printed, when the build ran it

one after another  311 ms
overlapping        103 ms
finish order was   10, 50, 90 — but the answer is [90, 10, 50]
ok   the results are the same either way
ok   one after another costs three delays
ok   overlapping costs about one
ok   so it really was faster, not just tidier
ok   and Future.wait answers in list order

Only reach for Future.wait when the calls are genuinely independent. If the second request needs the first request's id, sequential is not a mistake, it is the shape of the problem.

Future.any picks a winner. It does not stop the losers.

The loser carries on, because a Future cannot be cancelled

ran and analysed
final finished = <String>[];

Future<String> job(String name, int ms) async {
  await Future<void>.delayed(Duration(milliseconds: ms));
  finished.add(name);
  return name;
}

Future<void> main() async {
  final winner = await Future.any([job('slow', 150), job('fast', 20)]);
  final atTheFinish = List<String>.of(finished);

  // Wait around a little longer and look again.
  await Future<void>.delayed(const Duration(milliseconds: 200));

  print('winner            $winner');
  print('done at that moment  $atTheFinish');
  print('done a bit later     $finished');

  test('the first to complete wins', winner, 'fast');
  test('the slow one had not finished at that point', atTheFinish, ['fast']);
  test('but it kept running, and finished anyway', finished, ['fast', 'slow']);
  test('so Future.any chooses, it does not cancel', finished.length, 2);
}

What it printed, when the build ran it

winner            fast
done at that moment  [fast]
done a bit later     [fast, slow]
ok   the first to complete wins
ok   the slow one had not finished at that point
ok   but it kept running, and finished anyway
ok   so Future.any chooses, it does not cancel

That last assertion is the important one, and it is not a quirk of Future.any. A Dart Future has no cancel method at all. Racing two requests means paying for two requests. Timing one out means the slow one still finishes, still parses its body, and still writes to whatever it was going to write to. Lesson 13 is about what you do instead.

The mistake the compiler does catch

Most async bugs are invisible to the analyser. This one is not, and it is worth seeing the message once so you recognise it instantly.

This does not compile, on purpose

does not compile
Future<int> price() async => 1299;

Future<void> main() async {
  // It reads as though it worked. Without await, price() hands back the box,
  // not the number in it.
  final int pence = price();
  print(pence + 1);
}

What the analyser said

line 6: A value of type 'Future<int>' can't be assigned to a variable of type 'int'. Try changing the type of the variable, or casting the right-hand type to 'int'.
[invalid_assignment]

You only get that message because a type was written down. Assign it to var or pass it to something that takes Object and the mistake compiles cleanly and shows a user the text Instance of 'Future<int>'.

Where the event loop meets a rebuild

shown, not run
// The commonest Future bug in a Flutter app, and it compiles perfectly.
class PriceCard extends StatelessWidget {
  const PriceCard({super.key});

  @override
  Widget build(BuildContext context) {
    // WRONG. build() can run many times a second, and every run starts a new
    // request and discards the one before it.
    return FutureBuilder<int>(
      future: fetchPrice(),
      builder: (context, snap) => Text('${snap.data ?? '...'}'),
    );
  }
}

// Right: create the future once, somewhere that survives a rebuild.
class _PriceCardState extends State<PriceCard> {
  late final Future<int> _price = fetchPrice();

  @override
  Widget build(BuildContext context) => FutureBuilder<int>(
        future: _price,
        builder: (context, snap) => Text('${snap.data ?? '...'}'),
      );
}

Flutter code, and this part of the course runs plain Dart only. Lesson 31 runs widget code against a real tester.

If an interviewer asks you to explain the event loop, the answer that lands is not the definition. It is this: "one queue for callbacks that were already resolved, one for work that arrives from outside, and the first is always drained before the second — which is why a tight microtask loop freezes the UI and a tight timer loop does not."

9 Async errors, and the ones you never see

A synchronous error travels up the call stack, so a try anywhere above it will catch it. An async error does not: it travels along the future chain. If nothing is attached to that chain, the error has nowhere to go — and Dart's answer to nowhere is, quite often, silence.

The rule that explains all of it

  • await puts the error back on your call stack, so a try around the await works exactly as you expect.
  • No await means no stack to come back to. The try that started the call has already finished.
  • A future with no listener at all reports to its zone, which is a process-wide bucket, not a local handler.

The same failure, awaited and not awaited

ran and analysed
import 'dart:async';

class Flaky implements Exception {
  const Flaky(this.why);
  final String why;
  @override
  String toString() => 'Flaky: $why';
}

Future<int> load(String tag) async {
  await Future<void>.delayed(const Duration(milliseconds: 5));
  throw Flaky(tag);
}

Future<void> main() async {
  // Awaited. The failure comes back to this stack, so the catch fires.
  var awaited = 'nothing happened';
  try {
    await load('awaited');
  } on Flaky catch (e) {
    awaited = 'caught ${e.why}';
  }

  // Not awaited. Watch the ORDER: the try block finishes first.
  final order = <String>[];
  final done = Completer<void>();

  runZonedGuarded(() {
    try {
      load('unawaited');                 // started, then abandoned
      order.add('the try block completed normally');
    } on Flaky catch (_) {
      order.add('caught in the try');
    }
    Timer(const Duration(milliseconds: 40), done.complete);
  }, (error, stack) => order.add('the zone handler got it: $error'));

  await done.future;

  print('awaited   $awaited');
  for (final line in order) {
    print(line);
  }

  test('an awaited failure is caught by the try around it', awaited, 'caught awaited');
  test('the try around an un-awaited call caught nothing', order.first,
      'the try block completed normally');
  test('the failure surfaced later, and somewhere else', order.last,
      'the zone handler got it: Flaky: unawaited');
  test('two events, in that order', order.length, 2);
}

What it printed, when the build ran it

awaited   caught awaited
the try block completed normally
the zone handler got it: Flaky: unawaited
ok   an awaited failure is caught by the try around it
ok   the try around an un-awaited call caught nothing
ok   the failure surfaced later, and somewhere else
ok   two events, in that order

There is no lint on by default that will tell you about the missing await. Turn unawaited_futures on in analysis_options.yaml and it becomes a build error, and the handful of places you genuinely want fire-and-forget get an explicit unawaited(...) around them. That one line of config removes a whole category of bug, and being able to name it is a good answer to "how do you keep async code honest?"

The silent one

Here is the case worth remembering. Future.wait reports the first error and drops every other one. Not logs them. Drops them. The panel below installs a zone handler, which is the last place in an isolate an unhandled async error can possibly appear, and then asserts that it recorded nothing.

One error reported, one thrown away without a trace

ran and analysed
import 'dart:async';

class Flaky implements Exception {
  const Flaky(this.why);
  final String why;
  @override
  String toString() => 'Flaky: $why';
}

Future<int> ok(int v, int ms) async {
  await Future<void>.delayed(Duration(milliseconds: ms));
  return v;
}

Future<int> fail(String why, int ms) async {
  await Future<void>.delayed(Duration(milliseconds: ms));
  throw Flaky(why);
}

/// The fix. Each call carries its own handling, so failure becomes a value and
/// Future.wait never has an error to choose between.
Future<String> guarded(Future<int> call) async {
  try {
    return 'ok ${await call}';
  } on Flaky catch (e) {
    return 'failed ${e.why}';
  }
}

Future<void> main() async {
  final reported = <String>[];
  final zoneSaw = <String>[];
  final done = Completer<void>();

  runZonedGuarded(() {
    Future<void>(() async {
      try {
        // Two of these three fail.
        await Future.wait<int>([fail('first', 10), fail('second', 25), ok(3, 40)]);
      } on Flaky catch (e) {
        reported.add(e.why);
      }
      await Future<void>.delayed(const Duration(milliseconds: 80));
      done.complete();
    });
  }, (error, stack) => zoneSaw.add(error.toString()));

  await done.future;

  final all = await Future.wait(
      [guarded(fail('a', 10)), guarded(fail('b', 20)), guarded(ok(3, 5))]);

  print('Future.wait reported   $reported');
  print('the zone saw           $zoneSaw');
  print('guarded instead        $all');

  test('Future.wait reports exactly one error', reported.length, 1);
  test('and it is the first one to arrive', reported.first, 'first');
  test('the second failure was reported nowhere at all', zoneSaw, <String>[]);
  test('guarding each call keeps every answer', all, ['failed a', 'failed b', 'ok 3']);
}

What it printed, when the build ran it

Future.wait reported   [first]
the zone saw           []
guarded instead        [failed a, failed b, ok 3]
ok   Future.wait reports exactly one error
ok   and it is the first one to arrive
ok   the second failure was reported nowhere at all
ok   guarding each call keeps every answer

This is the shape of a real outage. Six uploads, five fail, the screen shows one message, and the log has one line in it. You spend a morning deciding the other five worked. Whenever a list of independent operations can fail independently, collect the outcomes rather than racing them for the right to be the error — the guarded wrapper above is the whole technique, and lesson 26 turns it into a type.

onError, catchError, and where each one sits

They look interchangeable and are not. then(onError:) handles a failure of the future it is attached to. catchError is a link further down the chain, so it also sees an error thrown by the then callback itself.

Three ways to handle one failure, and one that does not

ran and analysed
class Flaky implements Exception {
  const Flaky(this.why);
  final String why;
  @override
  String toString() => 'Flaky: $why';
}

Future<int> load(String tag) async {
  await Future<void>.delayed(const Duration(milliseconds: 5));
  throw Flaky(tag);
}

Future<void> main() async {
  // Upstream failure. Both forms handle this one.
  final upstream = await load('upstream')
      .then<String>((v) => 'value $v', onError: (Object e) => 'onError: $e');

  // The then callback itself throws. catchError is downstream, so it sees it.
  final afterThen = await Future<int>.value(1)
      .then<String>((v) => throw const Flaky('thrown inside then'))
      .catchError((Object e) => 'catchError: $e');

  // onError in the same position does not: it only ever looks upstream.
  String escaped;
  try {
    escaped = await Future<int>.value(1).then<String>(
        (v) => throw const Flaky('thrown inside then'),
        onError: (Object e) => 'onError: $e');
  } on Flaky catch (e) {
    escaped = 'escaped past onError: ${e.why}';
  }

  // And a test that does not match lets the error straight through.
  String wrongType;
  try {
    wrongType = await load('not a format problem')
        .then<String>((v) => 'ok')
        .catchError((Object e) => 'handled', test: (e) => e is FormatException);
  } on Flaky catch (e) {
    wrongType = 'passed through: ${e.why}';
  }

  print('onError, upstream      $upstream');
  print('catchError, after then $afterThen');
  print('onError, after then    $escaped');
  print('catchError with a test $wrongType');

  test('onError handles a failure of the future it is attached to', upstream,
      'onError: Flaky: upstream');
  test('catchError also catches what the then callback threw', afterThen,
      'catchError: Flaky: thrown inside then');
  test('onError does not, because it only looks upstream', escaped,
      'escaped past onError: thrown inside then');
  test('a test that does not match handles nothing', wrongType,
      'passed through: not a format problem');
}

What it printed, when the build ran it

onError, upstream      onError: Flaky: upstream
catchError, after then catchError: Flaky: thrown inside then
onError, after then    escaped past onError: thrown inside then
catchError with a test passed through: not a format problem
ok   onError handles a failure of the future it is attached to
ok   catchError also catches what the then callback threw
ok   onError does not, because it only looks upstream
ok   a test that does not match handles nothing

In new code, prefer try/catch around await. It reads top to bottom, it scopes properly, and it does not have a downstream-versus- upstream question in it. The chain forms are for when you are handed a future and want to transform it without an async function.

Zones: the bucket at the bottom

Some errors have no chain to travel along at all. A Timer callback runs in a later turn of the loop, so by the time it throws, the code that scheduled it is long gone from the stack.

The only handler that can see a timer throw

ran and analysed
import 'dart:async';

Future<void> main() async {
  final order = <String>[];
  final done = Completer<void>();

  runZonedGuarded(() {
    try {
      Timer(const Duration(milliseconds: 5), () => throw StateError('from a timer'));
      order.add('scheduled, and the try block ended');
    } on StateError catch (_) {
      order.add('caught in the try');
    }
    Timer(const Duration(milliseconds: 40), done.complete);
  }, (error, stack) => order.add('zone: $error'));

  await done.future;

  // A zone is not a try/catch you can reason about locally. It is isolate-wide,
  // and reaching it means nobody nearer the problem dealt with it.
  final backToRoot = Zone.current == Zone.root;

  for (final line in order) {
    print(line);
  }
  print('outside runZonedGuarded, in the root zone: $backToRoot');

  test('the try that scheduled the timer could not catch it', order.first,
      'scheduled, and the try block ended');
  test('the zone handler did', order.last, 'zone: Bad state: from a timer');
  test('nothing else saw it', order.length, 2);
  test('and the zone only applies inside its own callback', backToRoot, true);
}

What it printed, when the build ran it

scheduled, and the try block ended
zone: Bad state: from a timer
outside runZonedGuarded, in the root zone: true
ok   the try that scheduled the timer could not catch it
ok   the zone handler did
ok   nothing else saw it
ok   and the zone only applies inside its own callback

One async mistake the analyser does catch

This does not compile, on purpose

does not compile
/// The happy path returns. The other path returns nothing at all — which means
/// null, and the return type says null is not allowed.
Future<int> parsePrice(String? raw) async {
  if (raw != null) {
    return int.parse(raw);
  }
}

Future<void> main() async {
  print(await parsePrice('1299'));
}

What the analyser said

line 3: The body might complete normally, causing 'null' to be returned, but the return type, 'FutureOr<int>', is a potentially non-nullable type. Try adding either a return or a throw statement at the end.
[body_might_complete_normally]

Worth noticing what the message says: the return type it complains about is FutureOr<int>, not Future<int>. Inside an async body you are producing the value, and Dart wraps it for you.

The three hooks a production app installs

shown, not run
// Flutter has three places an error can land, and a release build wants all three.
void main() {
  // 1. Anything thrown inside the framework: build, layout, paint, gestures.
  FlutterError.onError = (details) {
    Crash.report(details.exception, details.stack);
  };

  // 2. Async errors that get all the way out, including un-awaited futures.
  PlatformDispatcher.instance.onError = (error, stack) {
    Crash.report(error, stack);
    return true;            // true means handled; false lets it end the app
  };

  // 3. And a zone, if you want your own startup code covered too.
  runZonedGuarded(() => runApp(const App()), Crash.report);
}

It needs the Flutter framework and a crash reporter, neither of which this part of the course loads. Shown for the shape, which is the part people miss.

The interview version of this lesson is one sentence: "an async error goes to whatever is listening to that future, and if nothing is, it goes to the zone — so the bug is almost never the throw, it is the missing await." Then name unawaited_futures, and say that Future.wait keeps one error and discards the rest.

10 Streams, single versus broadcast

A Future is one value, later. A Stream is many values, later, and it ends. Almost every stream bug in a Flutter app comes from one of two things: which kind of stream you have, and forgetting to let go of it.

Two kinds, and the difference matters

  • Single-subscription is the default. One listener, ever. Listening twice throws. An async* generator gives you one of these.
  • Broadcast allows many listeners, and delivers each event to whoever is listening at that moment. Nothing is buffered for latecomers.
  • A single-subscription stream does not start producing until something listens. That is why it can be replayed for its one listener, and why a broadcast one cannot.

A generator, read with await for, and listened to twice

ran and analysed
Stream<int> ticks(int n) async* {
  for (var i = 1; i <= n; i++) {
    await Future<void>.delayed(const Duration(milliseconds: 5));
    yield i;
  }
}

Future<void> main() async {
  // await for reads a stream like a for loop, and ends when the stream ends.
  final got = <int>[];
  await for (final t in ticks(3)) {
    got.add(t);
  }

  // The stream from a generator takes exactly one listener.
  final once = ticks(2);
  final first = await once.toList();
  String second;
  try {
    await once.toList();
    second = 'listened twice, no complaint';
  } on StateError catch (e) {
    second = e.message;
  }

  print('await for gave  $got');
  print('first listener  $first');
  print('second listener $second');

  test('await for collects every event, in order', got, [1, 2, 3]);
  test('the one listener gets the lot', first, [1, 2]);
  test('a second listener is refused outright', second,
      'Stream has already been listened to.');
  test('because a generator is not a broadcast stream', once.isBroadcast, false);
}

What it printed, when the build ran it

await for gave  [1, 2, 3]
first listener  [1, 2]
second listener Stream has already been listened to.
ok   await for collects every event, in order
ok   the one listener gets the lot
ok   a second listener is refused outright
ok   because a generator is not a broadcast stream

That StateError is the one people meet by accident. You pass a repository stream to two widgets, or to a StreamBuilder that gets rebuilt with a new subscription, and the app dies with "Stream has already been listened to." The fix is a decision, not a patch: either the stream is broadcast, or exactly one thing owns it and everything else reads from that owner.

Broadcast, and what it costs you

Broadcast solves the many-listeners problem and introduces a quieter one. An event emitted before you listened is gone. There is no buffer, no replay, and no warning.

Two listeners, and one of them missed the start

ran and analysed
import 'dart:async';

Future<void> main() async {
  final controller = StreamController<int>.broadcast();
  final early = <int>[];
  final arrivedLate = <int>[];

  final first = controller.stream.listen(early.add);
  controller.add(1);
  await Future<void>.delayed(Duration.zero);

  // This listener starts here. Event 1 has already been delivered and dropped.
  final second = controller.stream.listen(arrivedLate.add);
  controller.add(2);
  await Future<void>.delayed(Duration.zero);

  await first.cancel();
  await second.cancel();
  await controller.close();

  print('listening from the start  $early');
  print('listening after event 1   $arrivedLate');

  test('broadcast really does allow a second listener', arrivedLate.isNotEmpty, true);
  test('the first listener saw both events', early, [1, 2]);
  test('the second saw only what happened after it arrived', arrivedLate, [2]);
  test('and the stream says which kind it is', controller.stream.isBroadcast, true);
}

What it printed, when the build ran it

listening from the start  [1, 2]
listening after event 1   [2]
ok   broadcast really does allow a second listener
ok   the first listener saw both events
ok   the second saw only what happened after it arrived
ok   and the stream says which kind it is

If latecomers need the current value, a bare broadcast stream is the wrong tool. You want something that holds the last value and replays it on subscribe — which is what a ValueNotifier, a Bloc's state, or an rxdart BehaviorSubject all are. Reaching for one of those is the moment to be able to say why.

Transformers, and the one that reaches back

map, where and take each return a new stream. None of them does any work until something listens. And take does more than trim the output: when it has had enough it cancels its subscription, which stops the generator upstream. The counter below proves it.

take(3) does not trim the output, it stops the source

ran and analysed
var produced = 0;

/// A generator that records how far it actually got.
Stream<int> counted() async* {
  for (var i = 1; i <= 100; i++) {
    produced++;
    yield i;
  }
}

Future<void> main() async {
  final pipeline = counted().where((n) => n.isEven).map((n) => n * 10).take(3);
  final beforeListening = produced;

  final result = await pipeline.toList();

  print('produced before listening $beforeListening');
  print('result                    $result');
  print('produced in total         $produced');

  test('building a pipeline runs nothing at all', beforeListening, 0);
  test('the transformers did their work', result, [20, 40, 60]);
  test('take(3) stopped the generator at the sixth number', produced, 6);
  test('so ninety-four numbers were never produced', produced < 100, true);
}

What it printed, when the build ran it

produced before listening 0
result                    [20, 40, 60]
produced in total         6
ok   building a pipeline runs nothing at all
ok   the transformers did their work
ok   take(3) stopped the generator at the sixth number
ok   so ninety-four numbers were never produced

Cancel the subscription. Close the controller.

Two separate obligations, and both are silent when you skip them. An uncancelled subscription keeps calling your callback after the thing that owns it is gone — that is the setState() called after dispose() crash. An unclosed controller means onDone never fires, so anything waiting for the end waits forever.

One subscription cancelled, one left running

ran and analysed
import 'dart:async';

/// The shape of a State that listens to something. dispose() is the whole point.
class Meter {
  Meter(Stream<int> source) {
    _sub = source.listen((_) => seen++);
  }

  late final StreamSubscription<int> _sub;
  int seen = 0;

  Future<void> dispose() => _sub.cancel();
}

Future<void> main() async {
  final controller = StreamController<int>.broadcast();
  final tidy = Meter(controller.stream);
  final forgotten = Meter(controller.stream);

  controller.add(1);
  await Future<void>.delayed(Duration.zero);
  final bothAtStart = [tidy.seen, forgotten.seen];

  await tidy.dispose();            // one of them is cleaned up

  controller.add(2);
  controller.add(3);
  await Future<void>.delayed(Duration.zero);
  await controller.close();

  // Separately: onDone waits on close(), not on the last event.
  var doneFired = false;
  final other = StreamController<int>();
  final sub = other.stream.listen((_) {}, onDone: () => doneFired = true);
  other.add(9);
  await Future<void>.delayed(Duration.zero);
  final beforeClose = doneFired;
  await other.close();
  await Future<void>.delayed(Duration.zero);
  await sub.cancel();

  print('disposed  ${tidy.seen} events');
  print('forgotten ${forgotten.seen} events');
  print('onDone before close $beforeClose, after close $doneFired');

  test('both were listening for the first event', bothAtStart, [1, 1]);
  test('the disposed one stopped there', tidy.seen, 1);
  test('the forgotten one kept receiving', forgotten.seen, 3);
  test('onDone does not fire while the controller is open', beforeClose, false);
  test('and fires as soon as it is closed', doneFired, true);
}

What it printed, when the build ran it

disposed  1 events
forgotten 3 events
onDone before close false, after close true
ok   both were listening for the first event
ok   the disposed one stopped there
ok   the forgotten one kept receiving
ok   onDone does not fire while the controller is open
ok   and fires as soon as it is closed

Note what did not happen in that panel: no error, no log, no complaint. The forgotten Meter simply carries on doing work forever, holding its closure, its captured this, and through that the whole widget subtree it came from. That is a leak with no symptom until the memory graph is opened, which is why lesson 34 goes looking for it deliberately rather than hoping.

The element type is real

This does not compile, on purpose

does not compile
Stream<int> ticks() async* {
  yield 1;
  yield 2;
}

Future<void> main() async {
  // where() takes a predicate on the ELEMENT type, and these elements are ints.
  // Writing the parameter type down is what makes the analyser answer.
  final out = await ticks().where((String s) => s.isNotEmpty).toList();
  print(out);
}

What the analyser said

line 9: The argument type 'bool Function(String)' can't be assigned to the parameter type 'bool Function(int)'. 
[argument_type_not_assignable]

Leave the parameter untyped and it infers int, then isNotEmpty fails instead — still a compile error, but a more confusing one. Writing the type you believe you have is a cheap way to find out that you do not.

When you are asked to choose between a Future and a Stream, the honest answer is usually "does this value change after I read it?". One HTTP call is a Future. A database table you are showing, a websocket, a location feed, a text field — those are streams, and modelling them as repeated futures is where polling loops come from.

11 Debounce, throttle and backpressure

A producer that is faster than its consumer is not a rare edge case, it is a search field, a scroll position and a sensor. You have three honest choices: slow the producer down, drop events, or buffer them. Debounce and throttle are two different ways of dropping, and using the wrong one is a bug you can feel.

They are not the same thing

  • Debounce waits for quiet. Every new event restarts the clock, and you get the last value of a burst. Right for a search box.
  • Throttle works to a rate limit. You get the first value, then nothing for the length of the window. Right for a scroll handler.
  • Debounce can emit nothing at all while events keep coming. Throttle always emits at a steady rate. That is the choice, and it is about the feel of the thing.

Debounce and throttle, over one real stream of keystrokes

ran and analysed
import 'dart:async';

/// Someone typing: three quick keystrokes, a pause to think, then four more.
Stream<String> typing() async* {
  for (final s in ['f', 'fl', 'flu']) {
    yield s;
    await Future<void>.delayed(const Duration(milliseconds: 15));
  }
  await Future<void>.delayed(const Duration(milliseconds: 150));
  for (final s in ['flut', 'flutt', 'flutte', 'flutter']) {
    yield s;
    await Future<void>.delayed(const Duration(milliseconds: 15));
  }
}

/// Hold everything back until the source has been quiet for [quiet], then emit
/// the last value seen. Each new event cancels the pending timer.
Stream<T> debounce<T>(Stream<T> source, Duration quiet) {
  final out = StreamController<T>();
  Timer? timer;
  T? pending;
  var waiting = false;

  final sub = source.listen((value) {
    pending = value;
    waiting = true;
    timer?.cancel();
    timer = Timer(quiet, () {
      waiting = false;
      out.add(pending as T);
    });
  }, onDone: () {
    timer?.cancel();
    if (waiting) out.add(pending as T);   // flush what was still being held
    out.close();
  });

  out.onCancel = sub.cancel;
  return out.stream;
}

/// Emit the first value, then ignore everything for [window]. No timers at all.
Stream<T> throttle<T>(Stream<T> source, Duration window) {
  final out = StreamController<T>();
  DateTime? lastAt;

  final sub = source.listen((value) {
    final last = lastAt;
    final now = DateTime.now();
    if (last == null || now.difference(last) >= window) {
      lastAt = now;
      out.add(value);
    }
  }, onDone: out.close);

  out.onCancel = sub.cancel;
  return out.stream;
}

Future<void> main() async {
  // The same seven keystrokes through both, with the same 80ms setting.
  final both = await Future.wait([
    debounce(typing(), const Duration(milliseconds: 80)).toList(),
    throttle(typing(), const Duration(milliseconds: 80)).toList(),
  ]);
  final debounced = both[0];
  final throttled = both[1];

  print('seven keystrokes went in: f fl flu ... flut flutt flutte flutter');
  print('debounced  $debounced');
  print('throttled  $throttled');

  test('debounce waits for quiet, then emits the last of the burst', debounced,
      ['flu', 'flutter']);
  test('throttle emits the first, then shuts the window', throttled, ['f', 'flut']);
  test('so they disagree about which keystroke mattered',
      [debounced.first, throttled.first], ['flu', 'f']);
  test('both turned seven events into two', [debounced.length, throttled.length], [2, 2]);
}

What it printed, when the build ran it

seven keystrokes went in: f fl flu ... flut flutt flutte flutter
debounced  [flu, flutter]
throttled  [f, flut]
ok   debounce waits for quiet, then emits the last of the burst
ok   throttle emits the first, then shuts the window
ok   so they disagree about which keystroke mattered
ok   both turned seven events into two

Read the two results again. On a search box, throttle sends a query for f — three characters before the user finished the word — and never sends flutter at all. Debounce sends flu and flutter, which are the two things the user actually paused on. Same rate limit, completely different behaviour, and only one of them is what a search field should do.

When the consumer is slower than the producer

Dart gives you backpressure for free in one place and not at all in another, and the difference is push versus pull. A generator is pulled: yield does not come back until the listener is ready for more. A StreamController is pushed: add returns immediately and the events queue up.

A pulled producer waits. A pushed one does not.

ran and analysed
import 'dart:async';

Future<void> main() async {
  final made = <int>[];
  final eaten = <int>[];
  var aheadAfterFirst = -1;

  Stream<int> pulled() async* {
    for (var i = 1; i <= 5; i++) {
      made.add(i);
      yield i;
    }
  }

  // The body of this loop takes 10ms. The generator waits for it.
  await for (final n in pulled()) {
    await Future<void>.delayed(const Duration(milliseconds: 10));
    eaten.add(n);
    if (n == 1) aheadAfterFirst = made.length;
  }

  // A controller has no such brake. add() never waits for anybody.
  final controller = StreamController<int>();
  final delivered = <int>[];
  final sub = controller.stream.listen(delivered.add);
  for (var i = 1; i <= 1000; i++) {
    controller.add(i);
  }
  final rightAway = delivered.length;
  await Future<void>.delayed(const Duration(milliseconds: 20));
  final eventually = delivered.length;
  await sub.cancel();
  await controller.close();

  print('generator produced $made and the loop ate $eaten');
  print('after the first event the producer was $aheadAfterFirst ahead');
  print('controller: $rightAway delivered at once, $eventually a moment later');

  test('the loop got every event', eaten, [1, 2, 3, 4, 5]);
  test('and the generator never ran ahead of it', aheadAfterFirst, 1);
  test('a thousand adds are accepted without a word', rightAway, 0);
  test('and delivered from a queue you cannot see or measure', eventually, 1000);
}

What it printed, when the build ran it

generator produced [1, 2, 3, 4, 5] and the loop ate [1, 2, 3, 4, 5]
after the first event the producer was 1 ahead
controller: 0 delivered at once, 1000 a moment later
ok   the loop got every event
ok   and the generator never ran ahead of it
ok   a thousand adds are accepted without a word
ok   and delivered from a queue you cannot see or measure

That queue is the thing to worry about. It has no size limit, no metric and no error, so a producer that is permanently faster than its consumer does not fail — it grows until the process is killed. If you own the producer, the fix is to make it pull. If you do not, the fix is to drop events deliberately.

pause and resume

A subscription can be paused, and pausing buffers rather than drops. That matters: it is a brake on delivery, not a way of shedding load.

Pausing holds events. It does not lose them.

ran and analysed
import 'dart:async';

Future<void> main() async {
  final controller = StreamController<int>();
  final got = <int>[];
  final sub = controller.stream.listen(got.add);

  controller.add(1);
  await Future<void>.delayed(Duration.zero);

  // Paused. The producer carries on regardless.
  sub.pause();
  controller.add(2);
  controller.add(3);
  await Future<void>.delayed(const Duration(milliseconds: 20));
  final whilePaused = List<int>.of(got);
  final wasPaused = sub.isPaused;

  sub.resume();
  await Future<void>.delayed(const Duration(milliseconds: 20));
  final afterResume = List<int>.of(got);
  final stillPaused = sub.isPaused;

  await sub.cancel();
  await controller.close();

  print('while paused $whilePaused');
  print('after resume $afterResume');

  test('nothing arrives while a subscription is paused', whilePaused, [1]);
  test('the subscription says so', wasPaused, true);
  test('resuming delivers everything that was held', afterResume, [1, 2, 3]);
  test('so pause buffers, it does not drop', afterResume.length - whilePaused.length, 2);
  test('and resume really cleared it', stillPaused, false);
}

What it printed, when the build ran it

while paused [1]
after resume [1, 2, 3]
ok   nothing arrives while a subscription is paused
ok   the subscription says so
ok   resuming delivers everything that was held
ok   so pause buffers, it does not drop
ok   and resume really cleared it

pause() is not a load-shedding tool. Pausing a firehose for two seconds buys you a two-second backlog, delivered in a rush the moment you resume, which is usually worse than the original problem. Its real use is the short, bounded kind: sub.pause(someFuture) holds delivery until that future completes, which is how you stop a feed while a dialog is open.

Why a fast scroll is the case that matters

All of the above is abstract until you put it on a 120Hz screen. A scroll notification fires on every frame the finger moves, so a flick down a ListView can call your listener fifty times in a second. If that listener does anything at all — a setState, a read of layout, an infinite-scroll check that starts a request — you have chosen to do that work fifty times, and the frame budget is eight milliseconds.

The same operator, applied where it earns its keep

shown, not run
// The version that janks. Every frame of the flick asks for another page.
class _FeedState extends State<Feed> {
  final _controller = ScrollController();

  @override
  void initState() {
    super.initState();
    _controller.addListener(_onScroll);
  }

  @override
  void dispose() {
    _controller.removeListener(_onScroll);      // lesson 10's obligation
    _controller.dispose();
    super.dispose();
  }

  void _onScroll() {
    if (_controller.position.extentAfter < 400) {
      context.read<FeedCubit>().loadMore();     // fired again, and again
    }
  }
}

// Throttled by hand, which is all the operator above was doing. loadMore() is
// now asked at most three times a second, and the cubit's in-flight guard does
// the rest. Throttle, not debounce: the user is still scrolling, so waiting for
// quiet would mean the page never loads until they stop.
var _lastAsk = DateTime.fromMillisecondsSinceEpoch(0);

void _onScroll() {
  final now = DateTime.now();
  if (now.difference(_lastAsk) < const Duration(milliseconds: 300)) return;
  _lastAsk = now;
  if (_controller.position.extentAfter < 400) {
    context.read<FeedCubit>().loadMore();
  }
}

Flutter code with a scroll controller and a cubit, so it needs a running app rather than a Dart VM. Lesson 33 measures the jank the first version causes.

The mistake you will make writing one of these

This does not compile, on purpose

does not compile
import 'dart:async';

/// The whole helper is about waiting, and the waiting is where it breaks: a
/// number of milliseconds is not a Duration, and Dart will not guess.
Stream<String> debounce(Stream<String> source, int quietMs) {
  final out = StreamController<String>();
  Timer? timer;
  source.listen((value) {
    timer?.cancel();
    timer = Timer(quietMs, () => out.add(value));
  }, onDone: out.close);
  return out.stream;
}

Future<void> main() async {
  print(await debounce(const Stream<String>.empty(), 300).toList());
}

What the analyser said

line 10: The argument type 'int' can't be assigned to the parameter type 'Duration'. 
[argument_type_not_assignable]

rxdart gives you debounceTime and throttleTime and you should probably use them. Write them once anyway, because the interview question is not "which package" — it is "what is the difference, and which one would you put on a search field". Having built both, the answer takes ten seconds instead of a guess.

12 Isolates: real parallelism, and its cost

Everything in the last four lessons happened on one thread. await does not make anything parallel; it interleaves. When the problem is genuinely CPU-bound, interleaving does not help, and you need a second isolate — which shares no memory at all with the first.

What an isolate is

  • Its own memory and its own event loop. Two isolates run at the same time, on different cores.
  • Nothing is shared. Every value that crosses the boundary is copied, including everything a closure captured.
  • So there are no locks, no data races, and no volatile. That is the trade: safety by construction, paid for in copying.

Two CPU-bound jobs, timed three ways

ran and analysed
import 'dart:isolate';

/// Genuinely CPU-bound. No IO, no waiting, and trial division on purpose.
int countPrimes(int limit) {
  var n = 0;
  for (var i = 2; i < limit; i++) {
    var isPrime = true;
    for (var d = 2; d * d <= i; d++) {
      if (i % d == 0) {
        isPrime = false;
        break;
      }
    }
    if (isPrime) n++;
  }
  return n;
}

Future<void> main() async {
  // Two jobs, one after the other, here.
  final serial = Stopwatch()..start();
  final a = countPrimes(300000);
  final b = countPrimes(300000);
  serial.stop();

  // One job on its own isolate, for a baseline.
  final alone = Stopwatch()..start();
  await Isolate.run(() => countPrimes(300000));
  alone.stop();

  // Both jobs, on two isolates, at once.
  final parallel = Stopwatch()..start();
  final pair = await Future.wait([
    Isolate.run(() => countPrimes(300000)),
    Isolate.run(() => countPrimes(300000)),
  ]);
  parallel.stop();

  print('two, one after the other  ${serial.elapsedMilliseconds} ms');
  print('one, on its own isolate   ${alone.elapsedMilliseconds} ms');
  print('two, on two isolates      ${parallel.elapsedMilliseconds} ms');

  test('trial division gets the well-known answer', countPrimes(10000), 1229);
  test('the isolates computed the same thing', pair, [a, b]);
  test('two at once beat two in a row',
      parallel.elapsedMilliseconds < serial.elapsedMilliseconds, true);
  test('and cost about what one alone cost, which is what parallel means',
      parallel.elapsedMilliseconds < alone.elapsedMilliseconds * 2, true);
}

What it printed, when the build ran it

two, one after the other  102 ms
one, on its own isolate   67 ms
two, on two isolates      59 ms
ok   trial division gets the well-known answer
ok   the isolates computed the same thing
ok   two at once beat two in a row
ok   and cost about what one alone cost, which is what parallel means

Try that last assertion with Future.wait and no isolates and it fails flat: the two jobs would take exactly as long as running them in sequence, because await only helps when something is waiting. A tight loop is never waiting. This is the cleanest way to say what asynchrony is not.

No shared memory at all

The closure you hand to Isolate.run is sent across, and so is everything it captured. Sent, not shared. Mutating a captured map inside the isolate changes a copy, and the copy dies with the isolate.

A mutation that cannot escape, and the price of the boundary

ran and analysed
import 'dart:isolate';

Future<void> main() async {
  final ledger = <String, int>{'seats': 10};

  final inside = await Isolate.run(() {
    ledger['seats'] = 0;                 // this is a copy, not the map above
    ledger['note'] = 1;
    return Map<String, int>.of(ledger);
  });

  // Copying is not free. A million integers sent in costs real time; building
  // the same million inside the isolate costs almost none.
  final big = List<int>.generate(1000000, (i) => i);

  final sentIn = Stopwatch()..start();
  final sumOfCopy = await Isolate.run(() => big.fold<int>(0, (x, y) => x + y));
  sentIn.stop();

  final builtInside = Stopwatch()..start();
  final sumOfLocal = await Isolate.run(() {
    var total = 0;
    for (var i = 0; i < 1000000; i++) {
      total += i;
    }
    return total;
  });
  builtInside.stop();

  print('the isolate saw   $inside');
  print('back here it is   $ledger');
  print('sending it in     ${sentIn.elapsedMicroseconds} us');
  print('building it there ${builtInside.elapsedMicroseconds} us');

  test('the isolate really did change its own copy', inside, {'seats': 0, 'note': 1});
  test('and the original is untouched', ledger, {'seats': 10});
  test('both arrived at the same sum', sumOfCopy, sumOfLocal);
  test('but carrying a million integers across cost measurably more',
      sentIn.elapsedMicroseconds > builtInside.elapsedMicroseconds, true);
}

What it printed, when the build ran it

the isolate saw   {seats: 0, note: 1}
back here it is   {seats: 10}
sending it in     15867 us
building it there 2166 us
ok   the isolate really did change its own copy
ok   and the original is untouched
ok   both arrived at the same sum
ok   but carrying a million integers across cost measurably more

So the design rule is about the boundary, not the work: send an instruction, get back a summary. Sending a large object in and getting a large object out means you pay for two copies, and for a mid-sized payload that can cost more than the work itself.

When it is worth it, and when it is not

Here is the part that surprises people. Moving a jsonDecode onto another isolate does not make the decode faster. It costs the same or slightly more. What you buy is a main isolate that is free to keep drawing frames — which is the entire point, and a different claim from "faster".

The same decode, on and off the main isolate

ran and analysed
import 'dart:convert';
import 'dart:isolate';

String payloadOf(int rows) => jsonEncode({
      'items': List.generate(rows, (i) => {'id': i, 'name': 'row $i'}),
    });

Future<void> main() async {
  final payload = payloadOf(3000);

  // CPU-bound, and on this isolate it blocks every frame until it finishes.
  final here = Stopwatch()..start();
  final onMain = (jsonDecode(payload) as Map<String, dynamic>)['items'] as List;
  here.stop();

  // The same work elsewhere. Note what comes back: a count, not the whole list.
  final away = Stopwatch()..start();
  final offMain = await Isolate.run(() {
    final decoded = jsonDecode(payload) as Map<String, dynamic>;
    return (decoded['items'] as List).length;
  });
  away.stop();

  // IO-bound is a different problem. The wait is not the CPU's, so four
  // overlapping requests already cost about one request's worth of time.
  Future<int> request(int ms) async {
    await Future<void>.delayed(Duration(milliseconds: ms));
    return ms;
  }

  final io = Stopwatch()..start();
  final bodies = await Future.wait([request(60), request(60), request(60), request(60)]);
  io.stop();

  print('decode here    ${here.elapsedMicroseconds} us');
  print('decode away    ${away.elapsedMicroseconds} us');
  print('four requests  ${io.elapsedMilliseconds} ms');

  test('the same rows come back either way', [onMain.length, offMain], [3000, 3000]);
  test('moving it off did not make the decode faster',
      away.elapsedMicroseconds > here.elapsedMicroseconds ~/ 2, true);
  test('four overlapping waits cost about one wait', io.elapsedMilliseconds < 150, true);
  test('so IO wants await, not an isolate', bodies, [60, 60, 60, 60]);
}

What it printed, when the build ran it

decode here    20195 us
decode away    23095 us
four requests  69 ms
ok   the same rows come back either way
ok   moving it off did not make the decode faster
ok   four overlapping waits cost about one wait
ok   so IO wants await, not an isolate

The rule that follows: an isolate is for CPU, never for waiting. Network calls, file reads, database queries and platform channels are all already asynchronous and already off the main thread where it matters. Wrapping one in an isolate adds a boundary, two copies and a spawn, and removes nothing. Reach for an isolate when a profile shows a long synchronous gap: a big decode, image processing, cryptography, or parsing something you were unwise enough to receive as CSV.

Isolate.run always hands back a Future

This does not compile, on purpose

does not compile
import 'dart:isolate';

int countPrimes(int limit) => limit;

/// The answer has to travel back across the isolate boundary, so it arrives
/// later, by definition. There is no synchronous version of this.
int primesOffMain(int limit) {
  return Isolate.run(() => countPrimes(limit));
}

void main() {
  print(primesOffMain(100));
}

What the analyser said

line 8: A value of type 'Future<int>' can't be returned from the function 'primesOffMain' because it has a return type of 'int'.
[return_of_invalid_type]

compute(), and the capture that throws

shown, not run
// Flutter's compute() came first and does the same job with a tighter signature:
// a top-level or static function, and exactly one argument.
Future<List<Post>> parsePosts(String body) => compute(_decodePosts, body);

List<Post> _decodePosts(String body) => (jsonDecode(body) as List)
    .map((j) => Post.fromJson(j as Map<String, dynamic>))
    .toList();

// The trap that costs an afternoon. This compiles cleanly and throws at run time,
// because the closure captured something that cannot be copied. Controllers,
// sockets, database handles, BuildContext and anything holding one are all out.
Future<void> willThrow(StreamController<int> sink) =>
    Isolate.run(() => sink.add(1));
// Invalid argument(s): Illegal argument in isolate message: object is unsendable

// For a long-lived worker — a decoder you call fifty times — Isolate.run is the
// wrong shape, because it spawns and tears down every call. That case wants
// Isolate.spawn with a ReceivePort kept open, or a package that pools them.

The second block throws at run time rather than failing analysis, so it cannot be one of the executed panels, and compute() needs the Flutter framework.

One measured detail worth carrying into an interview: spawning an isolate on the Dart VM is far cheaper than people expect, because a new isolate in the same isolate group reuses the loaded program rather than starting a VM. The expensive part is almost never the spawn — it is the copy. So the question to ask about any candidate for an isolate is not "is this slow?" but "how much data has to cross?"

13 Retry, timeout, cancellation

Networks fail, and they fail in a shape: mostly briefly, sometimes for good. Three tools cover almost all of it — retry with backoff, a timeout, and a way to abandon work. The third one is awkward in Dart, and the reason it is awkward is the most useful thing in this lesson.

Retry, and why the delays matter

  • Backoff doubles the wait each time, because a server that just refused you is unlikely to be ready in the same millisecond.
  • Jitter multiplies that wait by a random factor, so ten thousand clients that all failed together do not all come back together.
  • A cap on attempts, and a rethrow of the real error. Retrying forever turns a broken request into a broken app.

Exponential backoff with jitter, and its exact schedule

ran and analysed
import 'dart:math';

typedef Sleep = Future<void> Function(Duration);

class Flaky implements Exception {
  const Flaky(this.why);
  final String why;
  @override
  String toString() => 'Flaky: $why';
}

final _rng = Random();
double _realJitter() => _rng.nextDouble();

/// Retry with exponential backoff and full jitter. Two things are parameters so
/// the schedule can be checked rather than waited for: how it sleeps, and where
/// its randomness comes from.
Future<T> retry<T>(
  Future<T> Function(int attempt) body, {
  required Sleep sleep,
  double Function() jitter = _realJitter,
  int attempts = 4,
  Duration base = const Duration(milliseconds: 100),
}) async {
  for (var i = 0;; i++) {
    try {
      return await body(i + 1);
    } on Flaky {
      if (i >= attempts - 1) rethrow;      // out of attempts: the caller's problem now
      await sleep(base * (1 << i) * jitter());
    }
  }
}

Future<void> main() async {
  // Rather than really sleeping, write down what it asked for.
  final waits = <int>[];
  Future<void> record(Duration d) async => waits.add(d.inMilliseconds);

  var calls = 0;
  final value = await retry<String>((attempt) async {
    calls++;
    if (attempt < 4) throw const Flaky('502');
    return 'worked on attempt $attempt';
  }, sleep: record, jitter: () => 0.5);

  // The other path: it never works, and the last error has to come out.
  final waits2 = <int>[];
  Future<void> record2(Duration d) async => waits2.add(d.inMilliseconds);
  String outcome;
  try {
    await retry<int>((_) async => throw const Flaky('host is down'),
        attempts: 3, sleep: record2, jitter: () => 1.0);
    outcome = 'somehow worked';
  } on Flaky catch (e) {
    outcome = 'gave up: ${e.why}';
  }

  print('attempts made $calls, result "$value"');
  print('delays with jitter 0.5  $waits');
  print('delays with jitter 1.0  $waits2');

  test('it kept going until the call worked', calls, 4);
  test('and returned the answer that worked', value, 'worked on attempt 4');
  test('the wait doubled each time, halved by the jitter', waits, [50, 100, 200]);
  test('with the jitter wide open it is the raw schedule', waits2, [100, 200]);
  test('three attempts means two waits, not three', waits2.length, 2);
  test('and the real error is rethrown, not swallowed', outcome, 'gave up: host is down');
}

What it printed, when the build ran it

attempts made 4, result "worked on attempt 4"
delays with jitter 0.5  [50, 100, 200]
delays with jitter 1.0  [100, 200]
ok   it kept going until the call worked
ok   and returned the answer that worked
ok   the wait doubled each time, halved by the jitter
ok   with the jitter wide open it is the raw schedule
ok   three attempts means two waits, not three
ok   and the real error is rethrown, not swallowed

Jitter is the part that gets left out, and it is the part that matters at scale. Without it, every client that failed during the same thirty-second outage retries at exactly 100ms, 200ms, 400ms — so the server comes back up and is immediately hit by the entire fleet, in lockstep, three times. That is a thundering herd, and one nextDouble() prevents it.

timeout, and what it does not do

timeout puts a ceiling on how long you wait. onTimeout lets you supply a value instead of an error, which is where a cached copy belongs. What it does not do is stop the call.

A timeout stops the waiting, not the work

ran and analysed
import 'dart:async';

final finished = <String>[];

Future<String> slowCall(String tag, int ms) async {
  await Future<void>.delayed(Duration(milliseconds: ms));
  finished.add(tag);
  return tag;
}

Future<void> main() async {
  // onTimeout supplies a value, so nothing is thrown at all.
  final fallback = await slowCall('a', 150)
      .timeout(const Duration(milliseconds: 30), onTimeout: () => 'cached copy');
  final atTimeout = List<String>.of(finished);

  // Without it, you get a TimeoutException that carries the duration.
  String thrown;
  try {
    await slowCall('b', 150).timeout(const Duration(milliseconds: 30));
    thrown = 'no timeout';
  } on TimeoutException catch (e) {
    thrown = 'threw after ${e.duration?.inMilliseconds} ms';
  }

  // Now wait around, and see what those two calls did with the rest of their lives.
  await Future<void>.delayed(const Duration(milliseconds: 220));

  print('fallback       $fallback');
  print('thrown         $thrown');
  print('finished then  $atTimeout');
  print('finished now   $finished');

  test('onTimeout gives a value instead of an error', fallback, 'cached copy');
  test('without it, timeout throws and says how long it waited', thrown,
      'threw after 30 ms');
  test('nothing had finished when the timeout fired', atTimeout, <String>[]);
  test('but both calls ran to completion regardless', finished, ['a', 'b']);
}

What it printed, when the build ran it

fallback       cached copy
thrown         threw after 30 ms
finished then  []
finished now   [a, b]
ok   onTimeout gives a value instead of an error
ok   without it, timeout throws and says how long it waited
ok   nothing had finished when the timeout fired
ok   but both calls ran to completion regardless

Look at that last assertion next to the third one. Both calls finished — after the timeout, after the fallback was already on screen. In a real app that means the request completed, the body was parsed, and whatever the success path does happened anyway: a cache write, a setState on a disposed widget, an analytics event for a screen the user left. A timeout is a promise about your patience, not about the server.

A Future cannot be cancelled, so you pass a token

There is no Future.cancel. Not hidden somewhere, not deprecated — it does not exist, because a Future is a handle on a result, not a handle on the work. Cancellation therefore has to be cooperative: the work agrees to check a flag, at points the work chooses.

Cooperative cancellation, and proof the work stopped

ran and analysed
import 'dart:async';

class Cancelled implements Exception {
  const Cancelled();
  @override
  String toString() => 'Cancelled';
}

/// The whole pattern. A flag, a way to set it, and a way to check it.
class CancelToken {
  var _cancelled = false;
  bool get isCancelled => _cancelled;

  void cancel() => _cancelled = true;

  void throwIfCancelled() {
    if (_cancelled) throw const Cancelled();
  }
}

/// Work in chunks, because a check is only possible between chunks. This is the
/// real constraint: one long synchronous call cannot be cancelled at all.
Future<void> upload(int chunks, CancelToken token, List<int> sent) async {
  for (var i = 1; i <= chunks; i++) {
    token.throwIfCancelled();
    await Future<void>.delayed(const Duration(milliseconds: 10));
    sent.add(i);
  }
}

Future<void> main() async {
  final sent = <int>[];
  final token = CancelToken();

  // Something else decides to give up part way through.
  Timer(const Duration(milliseconds: 45), token.cancel);

  String outcome;
  try {
    await upload(20, token, sent);
    outcome = 'finished all twenty';
  } on Cancelled catch (_) {
    outcome = 'cancelled';
  }
  final atCancel = sent.length;

  // Long enough for the remaining chunks, if anything were still running.
  await Future<void>.delayed(const Duration(milliseconds: 150));

  print('outcome     $outcome');
  print('sent by then $atCancel chunks');
  print('sent by now  ${sent.length} chunks');

  test('it stopped early', outcome, 'cancelled');
  test('having done some of the work first', atCancel > 0, true);
  test('but nowhere near all of it', atCancel < 20, true);
  test('and it really stopped, unlike the timeout above', sent.length, atCancel);
  test('the token remembers, so a late caller sees it too', token.isCancelled, true);
}

What it printed, when the build ran it

outcome     cancelled
sent by then 4 chunks
sent by now  4 chunks
ok   it stopped early
ok   having done some of the work first
ok   but nowhere near all of it
ok   and it really stopped, unlike the timeout above
ok   the token remembers, so a late caller sees it too

Compare the last assertion with the timeout panel. There, the work carried on and finished. Here it stopped, and it stopped because the code was written to be stoppable. That is the difference between wanting cancellation and having it.

Two consequences follow. Check the token after every await as well as before, because the world moves while you are suspended. And accept that a single long synchronous call — a big jsonDecode, an image resize — has no check points at all, so the only way to abandon it is to have put it on an isolate you can kill.

This does not compile, on purpose

does not compile
Future<String> load() async => 'body';

/// A retry loop has to wait between attempts. Waiting needs await, await needs
/// async, and async means this cannot return a plain String.
String fetchWithRetry() {
  for (var i = 0; i < 3; i++) {
    final body = await load();
    if (body.isNotEmpty) return body;
  }
  return '';
}

void main() {
  print(fetchWithRetry());
}

What the analyser said

line 7: The await expression can only be used in an async function. Try marking the function body with either 'async' or 'async*'.
[await_in_wrong_context]

The fix is not to remove the await. It is to accept that resilience is asynchronous all the way up, and change the return type to Future<String> — which is why "async creeps up the call stack" is a real design consequence rather than a complaint.

Retry, timeout and a token, composed

shown, not run
// The three ideas together, in the shape they end up in. Read the order.
class PostRepository {
  PostRepository(this._api);
  final Api _api;

  Future<List<Post>> load(String feed, {CancelToken? token}) {
    return retry<List<Post>>(
      (attempt) async {
        token?.throwIfCancelled();                 // before the work
        final body = await _api
            .get('/feeds/$feed')
            .timeout(const Duration(seconds: 8));
        token?.throwIfCancelled();                 // and again after the await
        return parsePosts(body);                   // lesson 12: this goes to an isolate
      },
      attempts: 3,
      sleep: Future<void>.delayed,                 // the real sleep, this time
    );
  }
}

// And the piece that is easiest to forget: most errors are not worth retrying.
// A 500 is their problem and may pass. A 401 will fail identically forever, and
// retrying it three times just makes the user wait three times as long to be
// told the same thing.
bool worthRetrying(Object error) => switch (error) {
      SocketException() => true,
      ApiError(status: 429 || >= 500) => true,
      _ => false,
    };

It needs an HTTP client and a live feed, and this course loads nothing from the network. Shown for the ordering, which is the part that gets written wrong.

If an interviewer asks how you would make a client resilient, the answer that sounds like experience has all four parts: back off with jitter, cap the attempts, retry only the errors that can pass, and carry a cancellation token because Dart cannot cancel a future for you. The last one is the one that shows you have shipped something, because it is only obvious after a screen has updated itself from a request the user abandoned.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.