Async in Dart
One thread, an event loop
Section titled “One thread, an event loop”Dart runs your code on a single thread with an event loop — the same model as JavaScript. There is no shared-memory threading in your normal code. Instead, async operations (network, files, timers) run elsewhere and schedule a callback on the event loop when they finish.
This is why “async” in Flutter does not mean “another thread.” It means “don’t block the one thread we have.” A slow synchronous loop freezes the UI; an await on a network call does not, because the thread is free to render frames while waiting.
flowchart LR ui["UI thread (event loop)"] -->|await fetch| free["thread is free to render frames"] free -->|response arrives| cb["callback scheduled on the loop"] cb --> resume["your code resumes"]
Microtasks vs. the event queue, proven in order
Section titled “Microtasks vs. the event queue, proven in order”The event loop actually has two queues: a microtask queue that fully drains before the loop touches the next real event-queue task, and the event queue itself (timers, I/O callbacks). scheduleMicrotask is the only true microtask of the three primitives below — Future(() {}) looks like one, but api.dart.dev is explicit that its constructor “[creates] a future containing the result of calling computation asynchronously with Timer.run” — a real event-queue task, exactly like an explicit Timer.run.
import 'dart:async';
// Records the relative firing order of three scheduling primitives.// scheduleMicrotask is the only TRUE microtask here — microtasks fully// drain before the event queue is touched again. `Future(() {})` LOOKS// like a microtask but api.dart.dev is explicit that its constructor// "[creates] a future containing the result of calling computation// asynchronously with Timer.run" — it is a real event-queue task, exactly// like an explicit Timer.run, ordered against Timer.run by REGISTRATION// order (both are event-queue tasks; the explicit Timer.run below is// registered first).List<String> recordOrder() { final order = <String>[]; order.add('sync-start'); Timer.run(() => order.add('timer.run')); // event queue, registered 1st Future<void>(() => order.add('future(() {})')); // event queue (via Timer.run internally), registered 2nd scheduleMicrotask(() => order.add('scheduleMicrotask')); // true microtask — drains before either event-queue task order.add('sync-end'); return order;}import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__async-in-dart/event_loop_order.dart';
void main() { test('scheduleMicrotask drains before either Timer-based task; Timer.run and Future(() {}) run in registration order', () async { final order = recordOrder(); expect(order, ['sync-start', 'sync-end']); // nothing async has run yet
// Let the microtask queue AND both event-queue tasks run. await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
expect(order, [ 'sync-start', 'sync-end', 'scheduleMicrotask', // the only true microtask — drains first 'timer.run', // event-queue task, registered 1st 'future(() {})', // event-queue task too (Future() uses Timer.run internally) — registered 2nd ]); });}✓ scheduleMicrotask drains before either Timer-based task; Timer.run and Future(() {}) run in registration order
00:00 +1: All tests passed!This is a genuinely surprising, easy-to-misremember result: Future(() {}) is not a microtask, and firing order between it and a real Timer.run follows registration order, not “microtasks vs. timers.” The only guarantee that’s actually safe to build on is: all pending microtasks (scheduleMicrotask, and the internal continuations await uses) run before the event loop moves on to its next real task.
Future and async/await
Section titled “Future and async/await”A Future<T> is a value that will exist later — the result of an async operation. async/await lets you write it as if it were sequential.
Future<User> fetchUser(int id) async { final response = await http.get(Uri.parse('/users/$id')); // waits, doesn't block return User.fromJson(response.body);}
// Calling it:Future<void> load() async { try { final user = await fetchUser(42); print(user.name); } catch (e) { print('failed: $e'); // awaited futures throw errors you can try/catch }}An async function always returns a Future. await unwraps it, and — crucially — errors from an awaited future surface as normal exceptions you can try/catch. In Flutter you rarely await in build; instead you hand the Future to a FutureBuilder (a later module).
Completer: bridging a callback API into a Future
Section titled “Completer: bridging a callback API into a Future”Not every API you consume gives you a Future — plenty are still callback-based. Completer lets you hold one end and hand out .future to callers, resolving it yourself when the callback fires:
import 'dart:async';
// Completer bridges a callback-style API into a Future — you hold the// Completer, hand out `.future` to callers, and resolve it yourself when// the callback fires.class CallbackApi { void onDone(void Function(String) callback) { Timer(const Duration(milliseconds: 5), () => callback('done')); }}
Future<String> asFuture(CallbackApi api) { final completer = Completer<String>(); api.onDone((result) => completer.complete(result)); return completer.future;}import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__async-in-dart/completer_demo.dart';
void main() { test('Completer turns a callback API into an awaitable Future', () async { final result = await asFuture(CallbackApi()); expect(result, 'done'); });}✓ Completer turns a callback API into an awaitable Future
00:00 +1: All tests passed!catchError vs. onError — a real, proven difference
Section titled “catchError vs. onError — a real, proven difference”.then(onValue, onError:) and .catchError(...) look interchangeable but genuinely aren’t: onError on .then only catches an error from the future being chained onto, not an error thrown inside onValue itself.
// then(onValue, onError:) — onError only catches an error from the future// THIS call is chained onto; it does NOT catch an error thrown inside// onValue itself.Future<String> withOnError(Future<int> input) { return input.then<String>( (value) => throw StateError('bad value: $value'), // thrown INSIDE onValue onError: (Object e) => 'recovered via onError: $e', );}
// catchError, chained AFTER then() — catches errors from EITHER the// original future OR the onValue callback, because it's a separate step// in the chain that runs after both.Future<String> withCatchError(Future<int> input) { return input .then<String>((value) => throw StateError('bad value: $value')) .catchError((Object e) => 'recovered via catchError: $e');}import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__async-in-dart/catch_vs_onerror.dart';
void main() { test('then(onValue, onError:) does NOT catch an error thrown inside onValue itself', () async { // input succeeds, so onError is never triggered by the INPUT future — // but onValue itself throws, and that error propagates uncaught. await expectLater( withOnError(Future.value(1)), throwsA(isA<StateError>()), ); });
test('catchError chained AFTER then() DOES catch an error thrown inside onValue', () async { final result = await withCatchError(Future.value(1)); expect(result, 'recovered via catchError: Bad state: bad value: 1'); });}✓ then(onValue, onError:) does NOT catch an error thrown inside onValue itself✓ catchError chained AFTER then() DOES catch an error thrown inside onValue
00:00 +2: All tests passed!Note the explicit .then<String>(...) type argument on both — this course’s probe runs strict-inference, and without it the analyzer can’t infer past a callback whose only path always throws (it infers Never), producing a real invalid_return_type_for_catch_error on the later .catchError. Being explicit here isn’t just style; it’s what strict mode requires.
Future.wait: parallel awaiting, and eagerError
Section titled “Future.wait: parallel awaiting, and eagerError”Future.wait runs a list of futures concurrently and resolves once all of them do — proven below, including the real behavioral difference eagerError makes:
import 'dart:async';
Future<int> okAfter(int ms, int value) => Future.delayed(Duration(milliseconds: ms), () => value);
Future<int> failAfter(int ms, String message) => Future.delayed(Duration(milliseconds: ms), () => throw StateError(message));import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__async-in-dart/future_wait_demo.dart';
void main() { test('Future.wait collects every result, in input order, once all complete', () async { final results = await Future.wait([okAfter(5, 1), okAfter(1, 2), okAfter(3, 3)]); expect(results, [1, 2, 3]); // input order, not completion order });
test('eagerError: false (the default) waits for every future, even after one fails', () async { final stopwatch = Stopwatch()..start(); await expectLater( Future.wait([failAfter(1, 'fast fail'), okAfter(20, 99)]), throwsA(isA<StateError>()), ); // The slow one (20ms) still had to finish before Future.wait settled. expect(stopwatch.elapsedMilliseconds, greaterThanOrEqualTo(18)); });
test('eagerError: true completes as soon as the FIRST future fails, without waiting', () async { final stopwatch = Stopwatch()..start(); await expectLater( Future.wait([failAfter(1, 'fast fail'), okAfter(50, 99)], eagerError: true), throwsA(isA<StateError>()), ); // Completed near the 1ms failure, nowhere near the 50ms success. expect(stopwatch.elapsedMilliseconds, lessThan(30)); });}✓ Future.wait collects every result, in input order, once all complete✓ eagerError: false (the default) waits for every future, even after one fails✓ eagerError: true completes as soon as the FIRST future fails, without waiting
00:00 +3: All tests passed!The default (eagerError: false) is safer for cleanup — every future still gets to run to completion (so nothing is left half-finished) — but it means an early failure doesn’t save you any wall-clock time. eagerError: true trades that away for a faster failure signal.
unawaited_futures: the lint that catches a fire-and-forget Future
Section titled “unawaited_futures: the lint that catches a fire-and-forget Future”Calling an async function without awaiting, storing, or otherwise handling its Future is a real bug magnet — an error inside it becomes an uncaught async error (see runZonedGuarded, below) instead of something the caller’s try/catch sees. This course’s probe uses package:flutter_lints/flutter.yaml, and checking its actual rule list shows unawaited_futures is not included by default — it only ships in the stricter template, not the base one:
// @expect-error unawaited_futures is NOT enabled by this probe's default// analysis_options.yaml (flutter_lints/flutter.yaml + strict-casts/// strict-inference/strict-raw-types) — this fence demonstrates what the// diagnostic looks like once the rule is explicitly turned on, captured// from a separate scratch project with `linter: rules: - unawaited_futures`import 'dart:async';
Future<void> save() async {}
Future<void> handler() async { save(); // fire-and-forget inside an async function — a bug magnet}Turning it on (linter: rules: [unawaited_futures] in analysis_options.yaml) and running flutter analyze for real:
info • Missing an 'await' for the 'Future' computed by this expression. Try adding an 'await' orwrapping the expression with 'unawaited' • unawaited_futuresThe fix, when a fire-and-forget really is intentional, is unawaited() from dart:async — it’s a no-op at runtime, existing purely to tell both the lint and the next reader “yes, on purpose”:
import 'dart:async';
Future<void> save() async {}
Future<void> handler() async { unawaited(save()); // explicit: "yes, I meant to fire-and-forget this"}flutter analyze on this version reports zero issues, even with the rule enabled.
Stream: many values over time
Section titled “Stream: many values over time”A Stream<T> is like a Future that delivers many values — a sequence of events over time: web-socket messages, sensor readings, user input, database changes.
Stream<int> ticker() async* { // async* makes a Stream; yield emits a value for (var i = 1; i <= 3; i++) { await Future.delayed(const Duration(seconds: 1)); yield i; // emits 1, then 2, then 3 }}
// Consume with await-for or .listen:await for (final tick in ticker()) { print(tick); // 1, 2, 3 — one per second}The Future/Stream split maps cleanly to Flutter: one-shot async → FutureBuilder; a stream of updates → StreamBuilder. Getting this distinction right is most of what “async in Flutter” means.
Single-subscription vs. broadcast, proven with the real error
Section titled “Single-subscription vs. broadcast, proven with the real error”StreamController() defaults to single-subscription: built for exactly one listener. Listening twice is a real, immediate error — not a silent second copy of the data:
import 'dart:async';
// A single-subscription StreamController (the default): built for exactly// one listener, e.g. a one-shot request/response style stream.StreamController<int> singleSubscriptionController() => StreamController<int>();
// A broadcast StreamController: any number of listeners, like an event bus.StreamController<int> broadcastController() => StreamController<int>.broadcast();import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__async-in-dart/stream_controller_demo.dart';
void main() { test('a single-subscription stream throws the real error on a second listen', () { final controller = singleSubscriptionController(); controller.stream.listen((_) {}); // first listen — fine
expect( () => controller.stream.listen((_) {}), // second listen — not allowed throwsA( isA<StateError>().having( (e) => e.message, 'message', contains('Stream has already been listened to'), ), ), ); controller.close(); });
test('a broadcast stream allows multiple listeners with no error', () { final controller = broadcastController(); final received1 = <int>[]; final received2 = <int>[]; controller.stream.listen(received1.add); controller.stream.listen(received2.add); // second listener — fine, broadcast controller.add(1); controller.close(); });}✓ a single-subscription stream throws the real error on a second listen✓ a broadcast stream allows multiple listeners with no error
00:00 +2: All tests passed!Reach for .broadcast() the moment more than one widget needs to listen to the same underlying stream (a shared connectivity/auth-state stream, for instance) — trying to fan a single-subscription stream out to two StreamBuilders is exactly this error, in production.
Cancellation: StreamSubscription.cancel()
Section titled “Cancellation: StreamSubscription.cancel()”Every .listen(...) call returns a StreamSubscription — hold onto it and cancel() it (typically in dispose()) to stop receiving events and let the underlying source clean up:
import 'dart:async';
Stream<int> ticker() async* { var i = 0; while (true) { await Future<void>.delayed(const Duration(milliseconds: 5)); yield i++; }}import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__async-in-dart/cancellable_ticker.dart';
void main() { test('StreamSubscription.cancel() stops future events from arriving', () async { final received = <int>[]; final sub = ticker().listen(received.add);
await Future<void>.delayed(const Duration(milliseconds: 22)); await sub.cancel(); final countAtCancel = received.length; expect(countAtCancel, greaterThan(0)); // the infinite stream WAS ticking
await Future<void>.delayed(const Duration(milliseconds: 30)); expect(received.length, countAtCancel); // nothing arrived after cancel });}✓ StreamSubscription.cancel() stops future events from arriving
00:00 +1: All tests passed!Forgetting to cancel a subscription tied to a State is the StreamSubscription version of the classic Flutter leak: the stream keeps running, keeps invoking a callback that may reference a disposed State, exactly the mounted-guard territory covered in the lifecycle lesson.
Uncaught async errors: try/catch’s blind spot, and runZonedGuarded
Section titled “Uncaught async errors: try/catch’s blind spot, and runZonedGuarded”An error thrown from a callback nobody awaits — a Timer callback, a stream listener, a fire-and-forget Future — is not inside anybody’s try/catch, because the code that scheduled it has already returned by the time the callback runs. Only a zone’s error handler, set up with runZonedGuarded, is guaranteed to see it — which is exactly why Flutter apps wrap runApp() in one for production crash reporting.
import 'dart:async';
// An error thrown from a Timer callback (or any callback nobody `await`s)// is NOT inside anybody's try/catch — a normal try/catch around the call// that scheduled the Timer cannot see it, because that call already// returned before the callback runs. Only a zone's error handler (set up// with runZonedGuarded) is guaranteed to see it.void scheduleFailingTimer() { Timer(const Duration(milliseconds: 1), () { throw StateError('boom from a Timer callback'); });}import 'dart:async';import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__async-in-dart/run_zoned_guarded_demo.dart';
void main() { test('try/catch around the scheduling call cannot catch the Timer callback\'s later error', () async { Object? caughtByTryCatch; Object? caughtByZone; final completer = Completer<void>();
// A zone here is only the TEST's safety net for observing the escape // without crashing the whole test run — it plays the role production // code gets for free from runZonedGuarded around runApp(). await runZonedGuarded(() async { try { scheduleFailingTimer(); } catch (e) { caughtByTryCatch = e; // never runs: the throw hasn't happened yet } await Future<void>.delayed(const Duration(milliseconds: 10)); completer.complete(); }, (error, stack) => caughtByZone = error);
await completer.future; expect(caughtByTryCatch, isNull); // try/catch truly never saw it expect(caughtByZone, isA<StateError>()); // only the zone did });
test('runZonedGuarded is exactly this safety net, proven end to end', () async { final completer = Completer<Object>(); await runZonedGuarded( () async { scheduleFailingTimer(); await Future<void>.delayed(const Duration(milliseconds: 10)); }, (error, stack) => completer.complete(error), // the zone's error handler ); final caught = await completer.future; expect(caught, isA<StateError>()); expect(caught.toString(), contains('boom from a Timer callback')); });}✓ try/catch around the scheduling call cannot catch the Timer callback's later error✓ runZonedGuarded is exactly this safety net, proven end to end
00:00 +2: All tests passed!The first test is the real proof: caughtByTryCatch stays null — the try/catch genuinely never sees the error — while caughtByZone genuinely does. This is exactly the pattern the production-concerns lesson builds on for app-wide crash reporting, pairing runZonedGuarded with FlutterError.onError to catch the two different halves of Flutter’s error surface.
Isolates: true parallelism, when you need it
Section titled “Isolates: true parallelism, when you need it”The one exception to “single thread” is isolates — separate workers with their own memory that communicate by message passing (no shared state). You reach for one only for genuinely CPU-heavy work (parsing a huge JSON, image processing) that would otherwise jank the UI.
Isolate.run (Dart 2.19+, confirmed by api.dart.dev) is the simplest way to reach for one: spawn, run a closure, get the result back, and shut the isolate down, all in one call:
import 'dart:isolate';
// Isolate.run (Dart 2.19+) spawns an isolate, runs the closure there, sends// the result back, and shuts the isolate down — all in one call. It is the// low-level primitive that Flutter's own compute() wraps with extra// isolate-pool/lifecycle management (covered in the performance module).Future<int> sumSquares(List<int> values) { return Isolate.run(() { var total = 0; for (final v in values) { total += v * v; } return total; });}import 'package:flutter_test/flutter_test.dart';import 'package:probe/lessons/dart-foundations__async-in-dart/isolate_run_demo.dart';
void main() { test('Isolate.run genuinely runs on a separate isolate and returns the result', () async { final result = await sumSquares([1, 2, 3, 4]); expect(result, 1 + 4 + 9 + 16); });}✓ Isolate.run genuinely runs on a separate isolate and returns the result
00:00 +1: All tests passed!Flutter’s own compute() wraps Isolate.run with extra pool/lifecycle management aimed at Flutter apps specifically — the async-and-performance module covers compute() and isolate closure-capture pitfalls in depth. The rule of thumb stays the same either way: reach for an isolate only when the work is CPU-bound, not I/O-bound — an await http.get(...) already frees the event loop on its own; wrapping it in an isolate adds message-passing overhead for zero benefit.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated async Dart tends to get the happy path right and skip exactly the parts that only matter when something goes wrong: unawaited futures, the catchError/onError distinction, and cleanup on cancellation/dispose.
// AI-generated: "save the draft in the background when the user types"class DraftController { void onTextChanged(String text) { _repository.saveDraft(text); // async Future, not awaited, not stored }
Future<void> Function(String) get _saveDraft => _repository.saveDraft; late final Repository _repository;}Before trusting generated async code like this, check for:
- Every call to an
asyncfunction whose result is neitherawaited nor assigned — proven above, this is exactly whatunawaited_futuresexists to catch, and an error inside it becomes genuinely uncaught (visible only torunZonedGuarded), not something the surrounding code’stry/catchwill ever see. .then(onValue, onError:)used as if it were a universaltry/catchfor the whole chain — proven above, it only catches errors from the future it’s chained onto, not fromonValueitself.- A
.listen(...)call whoseStreamSubscriptionis discarded instead of stored andcancel()ed indispose()— the stream keeps delivering events (and running its callback) after the widget is gone. Future.waitused for independent operations where a fast failure matters (e.g., form validation calls) withouteagerError: true— proven above, the default silently waits for every slow operation to finish even after the first one has already failed.
🐛 Spot the bug
class SyncService {Future<void> pushChanges() async { await _api.upload(_pendingChanges);}
void scheduleSync() { Future.delayed(const Duration(seconds: 2), () { pushChanges(); // fire-and-forget from inside a Timer-based Future too });}
late final Api _api;final List<Change> _pendingChanges = [];}Show the answer
Two real problems compound here, both proven earlier in this lesson: pushChanges() inside the Future.delayed callback is a genuine fire-and-forget call (unawaited_futures territory) — and because it’s inside a Future.delayed callback rather than directly inside an awaited chain, any error from _api.upload becomes an uncaught async error, invisible to any try/catch anywhere in SyncService, and visible only to a runZonedGuarded handler around the whole app. The fix is unawaited(pushChanges()) if fire-and-forget is truly intentional (documenting the choice, and letting runZonedGuarded catch failures for reporting), or awaiting it and handling the error locally if a failed sync needs to update UI state.
Mental Model
Section titled “Mental Model”Every async primitive in Dart is really answering one question: who is responsible for an error, and when do they find out? await inside a try/catch gives the answer “the caller, synchronously in this function” — the cleanest case. A bare unawaited call, a Timer callback, a stream listener — all of these detach the error from any calling context, so the only remaining safety net is the zone, proven above to be the one thing that genuinely sees an error a try/catch provably cannot. The event loop’s two-queue design (microtasks vs. the real event queue) exists for the same reason futures do: to let Dart finish “this synchronous unit of work” completely, in order, before anything else — including another callback — gets to run and possibly observe half-updated state.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
await inside try/catch | Errors are local, synchronous-feeling, easy to reason about | Sequential by default — parallel work needs Future.wait explicitly |
Fire-and-forget (unawaited(...)) | Doesn’t block the caller on work it doesn’t need to wait for | Proven above: any failure becomes an uncaught async error, visible only to a zone handler, not the call site |
Future.wait(eagerError: true) | Fails fast — proven above, doesn’t wait out slow futures after the first failure | Proven above: futures still in flight keep running to completion anyway (Dart doesn’t cancel them), so “fast failure” doesn’t mean “less work happening” |
Broadcast StreamController | Any number of listeners, proven above with zero error | No buffering for a late listener by default — it misses everything emitted before it subscribed |
Isolate.run / compute() | Real CPU parallelism, proven above with a genuine second isolate | Message-passing overhead; only worth it for CPU-bound work, never for I/O that already frees the event loop |
Common Misconceptions
Section titled “Common Misconceptions”- “
Future(() {})is basically the same asscheduleMicrotask.” Disproven above with a real, ordered test:Future(() {})is implemented viaTimer.runperapi.dart.dev, and fires alongside a realTimer.run, strictly after every true microtask drains. - “
.then(onValue, onError:)catches every error in the chain, just likecatchError.” Disproven above:onErroronly catches an error from the future being chained onto — an error thrown insideonValueitself propagates uncaught unless a.catchErrorfollows. - “An unawaited
Future’s error will surface eventually, e.g. the next time something awaits it.” Disproven above: nothing in the example ever awaits it again, so the error goes straight to the zone’s uncaught-error handler (or crashes silently without one) — never back to anytry/catch. - “
Future.wait([...], eagerError: true)cancels the slower futures once one fails, saving real work.” Disproven above: Dart futures aren’t cancellable this way — the slow ones keep running in the background regardless;eagerErroronly changes whenFuture.waititself resolves, not what work actually happens. - “A single-subscription
StreamControllerjust performs slightly worse with two listeners.” Disproven above: the second.listen()call throws a real, immediateStateError(“Stream has already been listened to”) — it is a hard failure, not a soft degradation.