Master Dart async: the event loop's microtask vs event queue, Futures vs Streams, backpressure with async , and why Future.cancel() doesn't exist — plus fixes.
Most Dart async bugs I've debugged in production weren't in the async/await — they were in the mental model. A widget rebuilds after it's disposed, a stream fires twice, a "cancelled" network call still lands and overwrites fresh state. None of that is a language bug. It's what happens when you treat Dart's concurrency as magic instead of as a scheduler you can predict. So let's make it predictable — not by memorizing rules, but by understanding the one machine that runs everything: the event loop.
Once you can trace what the event loop does with your await, your timers, and your stream events, the surprises stop. You start designing for the scheduler instead of fighting it. That's the whole goal of this post.
Dart is single-threaded per isolate. There is no preemption inside your isolate — code runs to completion before anything else gets a turn. There are no locks, no data races on your own variables, no half-updated state visible to another thread, because there is no other thread sharing your memory. What makes Dart feel concurrent is a scheduler with two queues: the microtask queue and the event queue.
The rule is simple and worth memorizing: after the current synchronous code finishes, the event loop drains the entire microtask queue before it touches a single event. Only when microtasks are exhausted does it pull one event, run it to completion, and then drain microtasks again — over and over until both queues are empty.
scheduleMicrotask, and — crucially — from the continuations of a completed Future (the code after an await, or inside .then).Future.delayed, Timer), I/O completions (file reads, socket data), and stream data events.This ordering explains a classic gotcha:
void main() { print('sync start'); Future(() => print('event: Future()')); // event queue Future.microtask(() => print('microtask')); // microtask queue Future.value(42).then((_) => print('then')); // microtask (already complete) print('sync end');}// sync start// sync end// microtask// then// event: Future()Future(() => ...) schedules an event, so it runs last despite being written first. Future.microtask and the .then on an already-completed future are microtasks, so they jump ahead of any event. Once you internalize this, "why did my callback run before my timer?" stops being a mystery — it's just microtasks winning the priority fight, every time.
The practical warning that follows directly from the drain rule: never spin the microtask queue in a loop. If a microtask schedules another microtask forever, the event queue starves — timers never fire, I/O never completes, and in Flutter your frame never renders. Recursion via microtasks is a UI freeze that looks like a hang, not a crash. Nothing throws; the app just stops responding while one CPU core sits pinned.
// DON'T: this starves the event loop forever.void spin() { scheduleMicrotask(spin); // reschedules itself before any event can run}// If you need to yield between chunks of work, use an event, not a microtask:Future<void> chunkedWork(List<Item> items) async { for (final item in items) { process(item); await Future(() {}); // yields to the event queue — a frame can render, a tap can land }}await Future(() {}) (or Future.delayed(Duration.zero)) posts an event, which lets the loop pull real work — pointer events, timers, the next frame — before continuing. That's the difference between a responsive app and a beach ball.
A Future<T> is a single deferred value: it completes exactly once, with data or an error. That's the whole contract. If you await it twice you get the same result — you don't re-run the work. This trips people up when they treat a future like a lazy computation:
final f = fetchUser(); // work starts NOW, not when you awaitfinal a = await f;final b = await f; // same result as `a`; fetchUser did not run twice
If you want the work to run twice, call the function twice. A future is a handle to work already in flight, not a recipe.
A Stream<T> is zero-or-more values over time, terminated by a done signal or an error. The distinction people miss is single-subscription vs broadcast:
async* produces) allows exactly one listener and typically doesn't produce values until someone listens. Listen twice and you get an exception. This is what you want for a finite sequence you consume once — reading a file line by line, paging through an API..asBroadcastStream(), or a StreamController.broadcast()) allows many listeners but doesn't buffer for latecomers — subscribe late and you miss whatever already fired. This is right for genuinely fan-out events: a bus that many widgets observe, connectivity changes, a shared ticker.I've seen real bugs where someone made a controller broadcast "to fix a double-listen error," and silently started dropping events that arrived before the second listener attached. The exception went away and a much harder intermittent bug took its place. Reach for broadcast because you genuinely have multiple concurrent consumers, not to silence an exception.
You have two main ways to produce a stream. Prefer async* when the sequence is something you can express as a loop — it's less code and, as we'll see, gives you backpressure for free:
Stream<int> range(int start, int end) async* { for (var i = start; i < end; i++) { yield i; }}Reach for StreamController when values arrive from outside your control flow — a callback, a socket, a platform channel — and you need to add() them imperatively. Just know that a controller is the case where you have to think about backpressure yourself.
Backpressure is what happens when a consumer is slower than a producer. If you ignore it, you buffer unbounded data in memory until the process dies with an out-of-memory crash. Dart's single-subscription streams handle this automatically when you use async* generators or await for.
Here's the mechanism: when you await for over a stream and the loop body is slow, the subscription is paused between iterations. A well-behaved producer — including any async* function — sees that pause and stops producing until you're ready. The yield literally suspends until the listener pulls the next value.
Stream<int> countUp() async* { var i = 0; while (true) { yield i++; // suspends here until the listener pulls again print('produced ${i - 1}'); }}Future<void> main() async { await for (final n in countUp()) { if (n >= 3) break; await Future.delayed(const Duration(milliseconds: 100)); // slow consumer }}// The producer never races ahead — it prints one "produced" per consumed item.Notice there's no unbounded buffer anywhere: the generator is frozen at the yield until the consumer's 100 ms of work finishes. Even though countUp() is an infinite loop, memory stays flat.
Where backpressure breaks down is a raw StreamController you add() to on a timer or from a socket callback. The controller happily buffers everything you push, whether or not anyone reads it. If you're bridging a callback-based API into a stream, respect onPause/onResume and check controller.isPaused before adding, or you've built an unbounded queue with extra steps:
final controller = StreamController<Data>( onListen: startSource, onPause: pauseSource, // stop pulling from the socket when the consumer is slow onResume: resumeSource, // start again when it drains onCancel: stopSource,);void onSocketData(Data d) { if (controller.isPaused) return; // or drop, or hand off to a bounded buffer controller.add(d);}If your source genuinely can't be paused (hardware, a third-party callback that fires on its own clock), then you have a real design decision: drop events, sample them, or use a bounded buffer with an explicit eviction policy. What you must not do is pretend the problem away by add-ing into an unbounded controller and hoping the consumer keeps up.
New Dart developers go looking for future.cancel() and don't find it. This is not an oversight. A Future is a promise that a value will arrive — the work is already in flight before you hold the future. Cancelling it would mean the future never completes, and anyone await-ing it would hang forever, leaking every await above it in the call stack. So Dart doesn't offer it. What you actually want is one of three things.
Most real work is behind an abstraction that does support cancellation. StreamSubscription has .cancel(). Timer has .cancel(). Dart's HTTP Client and the http/dio packages expose cancellation — Dio has CancelToken. Cancel the operation at its origin and the future simply becomes irrelevant:
final cancelToken = CancelToken();final future = dio.get('/big-download', cancelToken: cancelToken);// later, user navigates away:cancelToken.cancel('screen disposed');// `future` now completes with a DioException you can ignore — the socket is freed.When you can't stop the work, guard against acting on a stale result. This is the pattern I use for search-as-you-type and any "latest wins" scenario:
int _requestId = 0;Future<void> search(String query) async { final id = ++_requestId; final results = await api.search(query); // can't truly cancel this if (id != _requestId) return; // a newer search superseded us — drop it setState(() => _results = results);}The network call still completes, but its result is discarded if a fresher query started. This is what fixes the "I typed fast and an old result overwrote the new one" bug — the classic race where response order doesn't match request order.
In a StatefulWidget, pair this with a mounted check before touching state, so a completion after dispose() doesn't throw the infamous "setState() called after dispose()":
Future<void> load() async { final data = await repo.fetch(); if (!mounted) return; // the widget is gone; don't rebuild it setState(() => _data = data);}future.timeout(duration) doesn't cancel the underlying work either — it just completes your future with a TimeoutException so you stop waiting:
final data = await repo.fetch().timeout( const Duration(seconds: 5), onTimeout: () => throw TimeoutException('fetch too slow'),);Combine timeout with real source cancellation (option 1) if you also need to free the resource — otherwise the request keeps running in the background even though you've moved on.
The unifying idea: cancellation lives at the boundary where the work happens — subscriptions, sockets, tokens, timers — not on the Future value. Once you stop expecting the future itself to be cancellable, the correct design falls out naturally.
One more thing the mental model clears up: where errors go. An error inside an async function completes its future with that error, and await re-throws it at the call site — so a normal try/catch around the await works exactly like synchronous code. The trap is the unawaited future:
someAsyncThing(); // fire-and-forget — if this throws, the error is unhandled
An error from a future nobody is awaiting or .catchError-ing becomes an unhandled async error and can crash the isolate (or, in Flutter, hit PlatformDispatcher.instance.onError). If you deliberately don't await something, either handle it (someAsyncThing().catchError(log)) or mark it with the unawaited() helper from dart:async so your intent is explicit and the linter stays quiet. Streams follow the same logic: an error event you don't handle with onError propagates and can tear down the subscription.
await continuations and .then on a completed future are microtasks; timers, I/O, and stream data are events.await Future(() {})) between chunks of long work.async*/await for; a raw StreamController you add() to is an unbounded buffer waiting to OOM unless you honor onPause/onResume.Future.cancel(). Cancel the source (subscription, CancelToken, Timer), ignore stale results with a request token, or timeout — and always guard setState with mounted..catchError them, or mark them with unawaited(); an unhandled async error can crash the isolate.None of this is exotic. It's just the difference between async that surprises you at 2 a.m. in a crash report and async you can trace with a finger on the screen. Learn the scheduler once, and every future, stream, and await in your codebase becomes something you can reason about — on purpose, ahead of time, instead of in the debugger.