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.
thencallbacks andscheduleMicrotaskland here. - The event queue gets one item per turn: timers, IO, gestures,
Future.delayed, and a plainFuture(...).
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.
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.
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.
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.
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.
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>'.
// 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
awaitputs the error back on your call stack, so atryaround theawaitworks exactly as you expect.- No
awaitmeans no stack to come back to. Thetrythat 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.
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.
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.
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.
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
/// 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.
// 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.
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.
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.
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.
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
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.
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.
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.
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 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
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.
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.
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".
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
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]
// 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.
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.
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.
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.
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.
// 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.