Async & the Event Loop
By the end of this lesson you will be able to trace, with total confidence, the exact printed order of ANY Dart program that mixes print, Future, scheduleMicrotask, Timer, and await — because you will understand the three-lane machine (call stack, microtask queue, event queue) that decides that order. You will also be able to use Future and Stream correctly: chaining, error handling, running work in parallel vs sequentially, cancelling subscriptions, and avoiding the pitfalls that trip up almost every intermediate Dart/Flutter developer.
1. Synchronous vs asynchronous
This is the single most important fact about Dart concurrency: a Dart isolate runs one thing at a time. There is no magic parallelism inside a single isolate — async code does not run on a background thread. What it gives you is the ability to not block that one chef while waiting for something slow (a network response, a timer, a file read) so the chef can do other useful work in the meantime and come back later.
Run it yourself (verify/d14.dart executes exactly this code and checks the printed lines; the timings are compared against thresholds, not quoted from memory): the same 10 ms timer is starved by a 60 ms blocking sleep but runs on time during an await.
Input size → what is feasible: a Flutter frame at 60 fps has a 16 ms budget; at ~108 simple steps per second that is only ≈ 1.6·106 steps, so one synchronous chunk of 107 steps (≈ 100 ms) drops about 6 frames — split it, or move it to an isolate (D15). Waiting (I/O, timers) is free because it does not hold the thread.
async makes my function run on another thread/in parallel." It does not. async/await is about not blocking the one thread you have while waiting — it never gives you two things truly executing at the exact same instant. Real parallel execution in Dart needs a separate isolate (its own chef, its own kitchen) — that's D15.2. The event loop: three lanes
Formally, Dart's single isolate has three moving parts:
- The call stack — whatever function is literally executing right now (see D07). While it's non-empty, nothing else runs.
- The microtask queue — small, high-priority follow-up tasks:
scheduleMicrotask(fn),Future.microtask(fn), and the callback of.then()/continuation of anawaiton an already-completed Future all land here. - The event queue (sometimes called the "macrotask" queue) — bigger, independent units of work:
Future(fn),Timer/Timer.run,Future.delayed, I/O completions, user-input events. These are what make Dart feel "event-driven".
Puzzle A as real code. The printed order is exactly the one the animation above traces (A E C D B): sync prints first, then BOTH microtasks in scheduling order, then the single event.
Input size → what is feasible: a puzzle has at most a few dozen statements, so trace it with the three-lane rule instead of running it — and never rely on the relative order of two events scheduled far apart in time.
Future.delayed(Duration.zero, ...) is "instant" and will run before other scheduled work. A ZERO duration only means "as soon as possible among events" — it still goes into the event queue, behind the ENTIRE microtask queue, every single time. "Zero delay" never means "skip the queue".Proof in code: three microtasks scheduled AFTER a Future.delayed(Duration.zero) still run before it.
3. More ordering puzzles (and try it yourself)
Let's trace a denser puzzle mixing Timer.run, Future.delayed(Duration.zero, …), Future.value().then(…) (an already-completed future), and scheduleMicrotask — all four land in different spots relative to each other.
Puzzle B as code with its verified output (1 6 4 5 2 3): the already-completed Future.value(10).then is a microtask; Timer.run and Future.delayed(zero) are events in creation order.
Now the subtlest rule of all: what happens when a microtask, while running, schedules another new microtask? Does that new one have to "wait its turn" behind already-queued events?
Puzzle D as code: MICRO C-nested was added while the queue was draining, yet it still beats EVENT B.
Timer, every incoming network response, every UI frame callback waiting in it — never gets a turn. the starve panel below proves this directly: a microtask that re-schedules itself 500 times runs all 500 times before a Timer(Duration.zero, …) that was queued first ever fires.The 500-microtask starvation experiment, runnable.
Input size → what is feasible: a few thousand chained microtasks finish in well under a millisecond and are harmless; an unbounded chain (n → ∞) starves every timer, input event and frame — hop onto the event queue with Future(fn) every few hundred steps (interview question 27 shows the fix).
Pick any of the four verified programs above and step through it lane-by-lane yourself:
4. Future: states, then/catchError/whenComplete, and what async/await really is
Future<T> is like a coat-check ticket handed to you the instant you drop off your coat — you don't have the coat yet, but you have a promise (the ticket) that you can later exchange for either the coat (a value) or an apology because it was lost (an error). The ticket itself has three possible states at any moment: uncompleted (still waiting), completed with a value, or completed with an error. Once it settles into a value or an error, it never changes again.Futures in code: isCompleted flips once, the then callback runs later as a microtask, and a second complete throws.
.then(onValue) attaches a callback for the value case; .catchError(onError) attaches one for the error case (and can "recover" by returning a normal value, turning the chain back into the value state); .whenComplete(fn) attaches a callback that runs no matter which way the Future settled — like a finally block.
The animated chain as runnable code; the printed log is the one animation 6 ends with.
async/await is not a different mechanism from Future — it's pure syntax sugar compiled on top of it. Every async function is compiled by Dart into a small state machine: each await is a possible "pause point". When execution reaches an await, Dart suspends the function exactly there (remembering which state/line to resume at and all its local variables), immediately hands control back to whoever called it, and schedules a continuation — "resume this function from here" — to run as a microtask once the awaited Future settles.
Puzzle C as code: asyncFn runs synchronously until its first await, so 2 prints before 3.
Desugaring side by side: the async/await function and its hand-written .then() twin print the same trace and return the same value; the q26 demo in the interview bank goes one step further and writes the state machine out by hand.
Input size → what is feasible: every await costs one microtask hop (microseconds), so awaiting 105 already-completed futures in a loop is fine (≈ 105 hops); but a loop of 108 awaits is ≈ 108 hops and will not finish in a second.
async function never blocks its caller — it runs synchronously up to its first await, then returns a (still-uncompleted) Future immediately. Everything after that first await runs later, as a microtask continuation, once the awaited value/error is ready.5. Errors in async code
await inside a try), they can apologize to your face and you can react (catch). But if you already walked away without asking for a receipt-confirmation (fired off a Future and never awaited or attached a handler to it), and it later turns out your coat was lost — there's nobody there to hear the apology. The loss still gets reported (loudly, to the whole building's manager), it just isn't your problem to handle anymore, which is usually a bug.Wrapping await someFuture() in an ordinary try/catch works exactly like catching a synchronous throw — because that's literally what the state machine does: turn a Future's error into a normal Dart exception at the await point.
Both animation cases as code: the awaited error is caught by try/catch; the un-awaited one reaches the Zone (here a runZonedGuarded handler).
How an error travels: through nested awaits up to the first catch, past plain .thens until a catchError/onError.
Future completes with an error and NOTHING is awaiting it or has attached a .catchError/try/catch, Dart does not swallow it silently — it reports it as an unhandled exception in the current Zone (by default this crashes a command-line app, or gets logged/reported by Flutter's error handling). "Fire and forget" a Future only safely if you deliberately intend to ignore errors — and say so explicitly with unawaited(...) (section 9), so future readers (and static analysis) know it was on purpose.6. Combining futures: parallel, races, timeouts
Two independent awaits written one after another run sequentially — the second doesn't even start until the first is fully done. If they don't depend on each other, that's wasted time. Future.wait([...]) starts every future immediately and waits for ALL of them together, so their waiting time overlaps.
The animation's 60 ms + 80 ms jobs as runnable code. The page used to claim "≈ 60 + 80" and "≈ max(60, 80)"; the panel now MEASURES both with a Stopwatch and checks them against thresholds with a few ms of slack for timer rounding.
Input size → what is feasible: with n independent waits of d ms each: sequential = n·d (n = 100, d = 50 ms → 5 s), Future.wait = d (50 ms); but starting n = 105 requests at once can exhaust sockets — cap the concurrency (interview question 19).
Future.any([...]) is a race: it completes with whichever future finishes first (the others keep running in the background, they're just ignored). .timeout(Duration) races a future against a clock — if the future doesn't settle in time, the returned future completes with a TimeoutException instead, without cancelling the original work.
Future.wait returns results in INPUT order, not finish order. By default (eagerError: false) it waits for EVERY future to settle and only then fails with the first error; pass eagerError: true to fail fast — the combined future then completes with the first error immediately while the other futures keep running in the background (their results and later errors are ignored). The panel below measures both. Interview bank Hard tier below asks you to implement a simplified version of this yourself with a Completer.Every combinator in one runnable panel: result order of Future.wait, default vs eagerError: true (measured), the Future.any race, and .timeout with and without onTimeout.
Input size → what is feasible: k futures → Future.wait / Future.any do O(k) work to attach callbacks (k = 105 is fine); the waiting time is set by the slowest (wait) or fastest (any) future, not by k.
7. Streams
Future is one coat-check ticket for ONE coat, a Stream is a conveyor belt that keeps delivering coats (or apologies) over time — zero, one, or many, until it eventually stops. A single-subscription stream is like a private conveyor belt built for exactly one person watching it (typical of reading a file or an async* generator) — a second person trying to watch the SAME belt gets turned away. A broadcast stream is a shared belt in a public square: any number of people can watch it at once, each independently, but if you show up late you missed whatever already went by.Single-subscription vs broadcast as code: buffering before the first listener, the StateError on a second listen, and a broadcast log with independent listeners (the animation's [A:1, B:1, A:2, B:2, B:3]).
listen((value) {...}) subscribes imperatively and returns a StreamSubscription you can later .cancel() (always cancel subscriptions you no longer need — a forgotten subscription can keep its source (and everything it captures) alive, a leak just like the dangling references covered in D13). await for (final v in stream) {...} is sugar for looping over a stream's values one at a time inside an async function. A generator function marked async* can use yield value; to produce a stream's values lazily, one await at a time, from inside a normal-looking loop.
async* + yield, await for, and the pipeline operators where, map, asyncMap, transform, all with exact printed output.
An async* body is lazy: nothing runs until someone listens, and each yield hands the value to the listener before the body continues.
listen, cancel and onDone: cancelling stops delivery, runs the generator's finally, and means onDone never fires.
Streams support the same lazy pipeline operators as Iterable (D07/D08): .where() filters, .map() transforms synchronously, .asyncMap() transforms with an async callback (useful for "for each item, call an API"), each returning a new Stream you can keep chaining or eventually .listen()/await for/.toList().
Backpressure. A fast producer and a slow consumer need a brake: subscription.pause() tells the stream to stop producing, and an async* body simply stays suspended at its yield until resume() — so the producer never gets ahead of the consumer and memory stays bounded.
Pausing in action: each value is produced only after the consumer resumed.
Input size → what is feasible: a stream of 106 events with a consumer that needs 1 ms each takes 1000 s; without pause()/a bounded buffer a fast single-subscription source would queue all 106 events in memory first.
.listen(). Broadcast: many simultaneous listeners, no history buffering, each subscription independent. Always .cancel() subscriptions you're done with.8. Completer: bridging callback APIs
Completer<T> is a blank coat-check ticket you print yourself: you hand out completer.future to anyone who wants to wait for the result, and separately, whenever the OLD callback-style API finally calls back with a value (or an error), you call completer.complete(value) (or completer.completeError(e)) exactly once to "fill in" that ticket. This is the standard bridge from an old callback-based API into modern Future-based code.The bridge as runnable code, plus the error path and the once-only rule (isCompleted guard).
.complete() (or .completeError()) more than once on the same Completer throws a StateError — a Completer, like the Future it backs, can only settle once. Guard against a flaky callback API firing twice by checking completer.isCompleted first.9. Zones, unawaited, and common pitfalls
A Zone is an execution context that can intercept things like print, uncaught errors, and scheduling Timers/microtasks — frameworks (and the panels on this page, via runZoned/runZonedGuarded) use zones to capture output or catch "unhandled" async errors that would otherwise crash the program, without changing your code at all. You rarely create zones yourself in app code, but it's worth knowing WHY an uncaught async error can still be reported even though nothing in your code explicitly caught it: some zone up the chain (often the framework's own) is watching.
Zones in four lines each: intercepting print, carrying a value across an await/timer, and collecting an uncaught timer error with runZonedGuarded.
unawaited(future) (from dart:async) marks a deliberately fire-and-forget Future so both human readers and the unawaited_futures/discarded_futures lints know it's intentional, instead of looking like a forgotten await.
forEach with an async callback: list.forEach(myAsyncFn) calls myAsyncFn for every item but never awaits any of them — the loop "finishes" instantly while every async callback is still pending in the background, usually not in the order you'd expect. Measured in the panel below. Fix: use an ordinary for (final item in list) { await doAsync(item); }, which genuinely waits for each one before starting the next.The pitfall, its fix and unawaited in one panel: forEach leaves the saves racing (they finish in reverse order here), the for loop keeps them in order.
discarded_futures and unawaited_futures lints exist specifically to catch "you called an async function and threw away its Future without awaiting or explicitly marking it unawaited(...)" — almost always a real bug (a database write that might silently fail, a navigation that races with a dispose call, etc).Quiz
Interview questions
Cheat sheet
| Concept | Rule / syntax |
|---|---|
| One isolate | Runs exactly one thing at a time; async ≠ parallel threads — it means "non-blocking", not "simultaneous" |
| Event loop order | 1) run stack to empty → 2) drain entire microtask queue (incl. newly-added ones) → 3) run ONE event → repeat |
| Goes to the microtask queue | scheduleMicrotask, Future.microtask, .then() on an already-completed Future, the continuation right after an await |
| Goes to the event queue | Future(fn), Timer/Timer.run, Future.delayed (even with Duration.zero), I/O |
| Future states | uncompleted → (value) or (error); once settled, never changes |
then / catchError / whenComplete | value handler / error handler (can recover) / always-runs cleanup, like finally |
async/await | Sugar over a compiler-generated state machine: runs sync up to first await, suspends, schedules a microtask continuation, returns a Future immediately |
| Errors | try/catch around await catches a rejected Future like a sync throw; an un-awaited, un-handled rejected Future is reported as an uncaught Zone error, not swallowed |
| Sequential vs parallel | Two awaits in a row = sequential (times add up); Future.wait([...]) = parallel (times overlap, total ≈ the slowest one) |
Future.any / .timeout() | Race several futures, take the first to settle / race one future against a clock, throwing TimeoutException on expiry |
| Streams | Single-subscription: one listener ever. Broadcast: many independent listeners, no replay. listen/await for/async* + yield to produce |
| Stream pipeline | .where() filter, .map() sync transform, .asyncMap() async transform — always .cancel() subscriptions when done |
Completer<T> | Manually create a Future to bridge an old callback API: complete(value) / completeError(e) exactly once |
unawaited(f) | Explicitly marks a deliberate fire-and-forget Future so lints/readers know it's intentional |
| Pitfall | list.forEach(asyncFn) never awaits anything — use a for loop with await instead |