Dart (3.13) Module 11 – Asynchronous programming
Dart's event loop, microtasks and futures
How Dart runs async code on one thread: the event and microtask queues, futures chained with then, catchError and whenComplete, and Completers.
What you will learn
- Explain how Dart's event loop takes work from its microtask and event queues
- Predict the order in which synchronous code, microtasks and timer callbacks run
- Create and chain futures with then, catchError and whenComplete
- Wrap a callback-based API in a future with a Completer
- Recognise synchronous work that holds up every timer and callback
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how Dart's event loop takes work from its microtask and event queues
- Predict the order in which synchronous code, microtasks and timer callbacks run
- Create and chain futures with then, catchError and whenComplete
- Wrap a callback-based API in a future with a Completer
- Recognise synchronous work that holds up every timer and callback
Before you start
On this page
A Dart program starts a timer that is due in 10 ms, then spends 300 ms adding numbers in a loop. The timer runs only after the loop has ended, at least 290 ms late, because the program has one thread and the timer’s callback must wait its turn.
Everything asynchronous in Dart, from timers and file reads to futures, works by that rule. This lesson shows the
loop that hands out the turns and its two queues, with programs that ran on Dart 3.13.5. You will predict the order
in which callbacks run, chain futures, handle their errors, adapt a callback API with a Completer and spot the work
that holds every other callback up.
One isolate runs one piece of code at a time
A timer with a delay of zero sounds as if it runs at once. This program starts one and then loops 20 million times:
// A timer with a zero duration still waits for main to finish.
import 'dart:async';
void main() {
Timer(Duration.zero, () => print('2. the zero-duration timer runs'));
var sum = 0;
for (var i = 0; i < 20000000; i++) {
sum += i;
}
print('1. main has finished its loop, sum $sum');
} Output
1. main has finished its loop, sum 199999990000000 2. the zero-duration timer runs
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run zero_timer.dart
Line 2 came after line 1, although the timer was due before the loop had even started. A Dart program runs in an
isolate: An independent unit of execution in Dart with its own memory and one thread that runs an event loop. Isolates share no mutable objects and cooperate by sending messages. More in “Isolates and Isolate.run for heavy work”,
and each isolate has one thread that runs one piece of code at a time. What decides the next piece is the
event loop: The loop inside each Dart isolate that runs one piece of code at a time: main first, then each waiting microtask, then the next event, each to its end, until nothing is left to wait for.: it runs main to its end, then takes the next waiting callback, runs it to
its end, and repeats until nothing is left to wait for. The Dart concurrency page describes it as a loop that
processes events one at a time, in the order they were queued.
A callback is never interrupted halfway. The timer’s callback was ready early, but it had to wait until main gave the
thread back. That is the whole model: no callback runs in parallel with another one in the same isolate, so two
callbacks never see each other’s half-finished work.
Checkpoint
Check: One isolate, one piece of code at a time
Microtasks run before the next event
The loop keeps two queues. The event queue: The queue of outside work an isolate's event loop takes one item at a time: timer callbacks, finished I/O and messages from other isolates, in the order they arrived. holds timer callbacks, finished I/O and
messages from other isolates. The microtask: A short follow-up job, such as a then callback of a completed future or a scheduleMicrotask call. The event loop runs every waiting microtask, in order, before it takes the next event. queue holds short follow-up jobs, such as the
callback of a future that has already completed. The rule between them: whenever the running code ends, main or an
event’s callback, the loop runs every waiting microtask, in order, before it takes the next event.
This program schedules work in both queues and numbers each print in the order it appears:
// Eight prints, numbered in the order they appear. Each line says which queue its callback waited in.
import 'dart:async';
Future<void> main() async {
print('1 main starts');
Timer.run(() => print('6 Timer.run: an event'));
Future(() => print('7 Future(() ...): an event'));
scheduleMicrotask(() => print('4 scheduleMicrotask: a microtask'));
Future.value('ready').then((_) => print('5 then on a completed future: a microtask'));
Future.sync(() => print('2 Future.sync: runs its callback now'));
print('3 main reaches its await');
await Future.pause(Duration.zero);
print('8 after await Future.pause(Duration.zero)');
} Output
1 main starts 2 Future.sync: runs its callback now 3 main reaches its await 4 scheduleMicrotask: a microtask 5 then on a completed future: a microtask 6 Timer.run: an event 7 Future(() ...): an event 8 after await Future.pause(Duration.zero)
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run async_order.dart
Future.sync ran its function at once, inside main, so 2 came before 3. The two microtasks came next, 4 and 5, in
the order they were scheduled. Only then did the loop take the events, 6 and 7, also in their order. The await in
main waited for a third timer, so 8 came last. Step through the same run and watch both queues:
The drawing shows the whole scene; its 11 steps are listed below.
Every step as text
- main starts. The loop's first job is main itself. It runs from its first line, and nothing else can run on this isolate until main stops. It prints 1.
- Timer.run adds an event. Timer.run does not run its callback now. It starts a timer with a zero delay, and the callback waits in the event queue.
- Future() adds another event. Future(() ...) also schedules its function with a timer, so it waits in the event queue too, behind Timer.run.
- scheduleMicrotask. scheduleMicrotask puts its callback in the microtask queue, which the loop empties before it takes any event.
- then on a completed future. Future.value('ready') is already complete, so then schedules its callback as a microtask, behind the one already waiting.
- Future.sync runs at once. Future.sync calls its function now, inside main, and wraps the result in a future. That is why 2 is printed before any queue is touched.
- main reaches its await. main prints 3. Future.pause(Duration.zero) starts a timer, a third event, and await hands control back to the loop until that timer fires.
- Microtasks first. main's synchronous part has ended. Before it looks at the events, the loop empties the microtask queue in order, 4 and then 5.
- Then the oldest event. With no microtask waiting, the loop takes the oldest event, the Timer.run callback, which prints 6. After each event it checks the microtask queue again.
- The next event. The microtask queue is still empty, so the loop takes the next event, the function given to Future(), which prints 7.
- The pause ends and main resumes. The pause timer fires and completes its future. That schedules the rest of main as a microtask, which runs at once and prints 8. Every queue is now empty.
The scheduleMicrotask documentation states the guarantee in so many words: its callbacks always run in order and
before other asynchronous events such as timers. Future.microtask(...) uses the same queue, while Timer.run,
Future(...) and Future.delayed(...) all wait in the event queue.
Key idea
Whenever the running code ends, the loop empties the microtask queue in order before it takes the next event, so a microtask always runs before any timer that is already waiting.
Checkpoint
Check: Microtasks before events
A future is a value that arrives later
A future: An object of type Future<T> that completes once, later, with a value of type T or with an error. Callbacks registered with then, catchError and whenComplete, or an await, receive the result. is an object that will complete once, either with a value or with an error. Code
that needs the value registers a callback with then, and then returns a new future for the callback’s own result,
so steps can be chained. This program looks up two prices, and the second item does not exist:
// Two price lookups chained with then, catchError and whenComplete. The second sku does not exist.
import 'dart:async';
class UnknownSkuException implements Exception {
UnknownSkuException(this.sku);
final String sku;
@override
String toString() => 'UnknownSkuException: no price for "$sku"';
}
const prices = {'pen': 1200, 'pad': 4500};
Future<int> fetchPrice(String sku) => Future.delayed(const Duration(milliseconds: 20), () {
final price = prices[sku];
if (price == null) throw UnknownSkuException(sku);
return price;
});
void lookUp(String sku) {
print('$sku: asking');
fetchPrice(sku)
.then((price) => price * 90 ~/ 100)
.then((price) => print('$sku: $price after 10 % off'))
.catchError((Object e) => print('$sku: lookup failed, $e'))
.whenComplete(() => print('$sku: done'));
}
void main() {
lookUp('pen');
lookUp('ink');
print('main: both lookups started');
} Output
pen: asking ink: asking main: both lookups started pen: 1080 after 10 % off pen: done ink: lookup failed, UnknownSkuException: no price for "ink" ink: done
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run price_chain.dart
Both lookups started before either finished: main: both lookups started came third, and the prices arrived 20 ms
later, after main had ended. For pen the value went through both then steps: 1200, then 1080 after the
discount. For ink, fetchPrice threw inside its delayed callback, so its future completed with an error. Both
then steps were skipped, catchError received the UnknownSkuException, and whenComplete ran on both paths, the
way finally runs after a try.
The ways to make a future differ in when their function runs and which queue it waits in:
| Constructor | Its function runs | Completes |
|---|---|---|
Future.sync(f) |
now, inside the caller | with f’s value or error |
Future.value(v), Future.syncValue(v) |
(no function) | with v; a then callback waits for a microtask |
Future.microtask(f) |
from the microtask queue | when f has run |
Future(f), Timer.run(f) |
from the event queue | when f has run (a timer has no future) |
Future.delayed(d, f) |
from the event queue, after d | when f has run |
Future.pause(d) |
(no function) | after d, never with an error |
Future.syncValue (3.10) and Future.pause (3.13)
Future.syncValue(v) arrived in Dart 3.10: unlike Future.value, it never accepts another future as its value.
Future.pause(d), a static method added in Dart 3.13, waits for d without running any code and cannot complete
with an error, which makes await Future.pause(Duration.zero) a plain way to let the loop run other work.
Checkpoint
Check: Futures and their chains
An error nobody handles still ends the program
A future’s error does not disappear when nobody listens for it. Here main starts a delayed computation that fails,
and nothing calls catchError on it:
// A future fails after main has returned, and nothing handles its error.
void main() {
Future.delayed(const Duration(milliseconds: 10), () => throw StateError('stock service down'));
print('main has run its last line');
} Output (exit status 255)
main has run its last line
Printed as an error (standard error)
Unhandled exception: Bad state: stock service down #0 main.<anonymous closure> (unhandled_future.dart:3:58) #1 new Future.delayed.<anonymous closure> (dart:async/future.dart:448:42) #2 Timer._createTimer.<anonymous closure> (dart:async-patch/timer_patch.dart:18:15) #3 _Timer._runTimers (dart:isolate-patch/timer_impl.dart:423:19) #4 _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:454:5) #5 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:192:12)
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run unhandled_future.dart
main printed its last line and returned. Ten milliseconds later the timer ran, the function threw, and because
nothing handled the future’s error, the runtime reported Unhandled exception: Bad state: stock service down and
ended the program with exit code 255, the same way as for an
exception thrown in synchronous code. The trace starts in the closure on line 3,
then shows the timer frames of the event loop that called it, not main, because main had already returned.
Starting a future and dropping it
fetchPrice('ink'); on a line of its own starts the lookup, but its error has nowhere to go. Keep the future and
handle it: chain .catchError(...) (or use try with await, the subject of the next lesson), so a failure is
reported where you decide.
Checkpoint
Check: Errors nobody handles
A Completer turns a callback API into a future
Many older libraries report back through callbacks: read(onReading, onFailure). A Completer: A dart:async object that creates a future and completes it later with complete(value) or completeError(error), often from the callbacks of an API that does not return futures. It completes only once.
adapts them. You create a Completer<T>, hand its future to your caller, and call complete(value) or
completeError(error) from the callbacks:
// A Completer turns a callback-style API into a Future.
import 'dart:async';
class ScaleException implements Exception {
ScaleException(this.reason);
final String reason;
@override
String toString() => 'ScaleException: $reason';
}
/// A callback-style API, the shape of many older libraries: it answers through onReading or onFailure.
class Scale {
var _reads = 0;
void read(void Function(double grams) onReading, void Function(String reason) onFailure) {
Timer(const Duration(milliseconds: 15), () {
if (_reads++ == 0) {
onReading(412.5);
} else {
onFailure('not settled');
}
});
}
}
Future<double> readGrams(Scale scale) {
final completer = Completer<double>();
scale.read(
completer.complete,
(reason) => completer.completeError(ScaleException(reason)),
);
return completer.future;
}
void main() {
final scale = Scale();
readGrams(scale)
.then((grams) => print('first reading: $grams g'))
.then((_) => readGrams(scale))
.then((grams) => print('second reading: $grams g'))
.catchError((Object e) => print('second reading failed: $e'));
final once = Completer<int>();
once.complete(1);
try {
once.complete(2);
} on StateError catch (e) {
print('completing twice: $e');
}
} Output
completing twice: Bad state: Future already completed first reading: 412.5 g second reading failed: ScaleException: not settled
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run completer.dart
The first reading arrived as 412.5 g, and the second callback reported a failure, which completeError turned into
a ScaleException for catchError. The first line of output, though, is the last block of main: it ran
synchronously, before any timer, and shows the one rule a Completer enforces. A future completes once, so a second
complete throws Bad state: Future already completed, a StateError. A callback API that may call back twice must
check isCompleted first.
Reach for a Completer only at that edge. The Completer documentation points out that most futures are simpler to
make with a Future constructor or a chain of then calls, and keeps the Completer for building a future from
scratch, as when a callback-based API is turned into a future-based one.
Checkpoint
Check: Completers for callback APIs
Long synchronous work holds up every callback
Because one thread runs one callback at a time, a long stretch of synchronous code holds up everything else in its
isolate: timers, I/O callbacks, microtasks and the rest of the program. This program makes the delay certain by working
for 300 ms in a loop that checks a Stopwatch:
// 300 ms of synchronous work holds up a timer that was due after 10 ms.
import 'dart:async';
void main() {
final clock = Stopwatch()..start();
Timer(const Duration(milliseconds: 10), () {
final late = clock.elapsedMilliseconds >= 300;
print('the 10 ms timer ran ${late ? 'only after the 300 ms of work' : 'on time'}');
});
while (clock.elapsedMilliseconds < 300) {
// Busy work: nothing else on this isolate can run until the loop ends.
}
print('busy work done after 300 ms');
} Output
busy work done after 300 ms the 10 ms timer ran only after the 300 ms of work
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run blocking.dart
The timer was due after 10 ms and ran only after the busy loop had ended. In a server, every request waiting for that isolate waits as long; in an app, the screen cannot be redrawn. The concurrency page’s remedy for work that takes long is to run it in another isolate, which has its own thread and event loop, so the main isolate can keep answering.
Checkpoint
Check: Work that blocks the loop
Practise: predict, then run
Each change below has a definite answer. Write your prediction down, then run the changed program with
dart run <file>.dart:
- In
async_order.dart, move thescheduleMicrotaskline below theFuture.valueline. Which two numbers swap? - Add
Future.microtask(() => print('M'));as the first line ofmain. Where doesMappear? - In
price_chain.dart, delete thecatchErrorline. What does the program print now, and with which exit code? - In
blocking.dart, try delays of 0, 1 and 10,000 ms for the timer. Can any of them print the timer’s line beforebusy work done? Explain the answer with the event loop.
Test your model of the loop here: each answer is explained as soon as you choose it, and your weakest topic comes up first:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What sets Dart’s microtask queue apart from its event queue?
Both hold callbacks that the event loop runs later, one at a time, on the isolate’s single thread. The event queue
holds outside work: timer callbacks, finished I/O and messages from other isolates. The microtask queue holds short
follow-up jobs, such as a then callback on a future that has already completed or a scheduleMicrotask call. The
difference is priority. Each time the running code ends, the loop runs every waiting microtask, in order, before it
takes the next event, so a microtask scheduled now runs before a timer that was due earlier. In async_order.dart both
microtasks, 4 and 5, printed before either event, 6 and 7.
Warm-up (fresher): What problem does a Completer solve?
It gives you a future for a result that arrives through a callback, so code written with futures can use an API that
was not. You create a Completer<T>, return its future at once, and call complete(value) from the success callback
or completeError(error) from the failure callback. Callers then chain then and catchError, or use await, as
with any other future. A completer completes only once: a second complete throws a StateError, so a callback that
can fire twice must check isCompleted.
The bank holds two longer questions where the loop’s rules decide a real failure:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A cache refresher in a Dart server walks 50,000 entries and, after each one, schedules the next entry with scheduleMicrotask so that it "does not block". After the deploy, request timeouts fire seconds late and socket reads stall during every refresh. Explain what is happening and fix the refresher.
-
Fresher to mid
A command line tool parses a 60 MB CSV file in one synchronous loop that takes about 4 seconds, while a Timer.periodic is meant to redraw a progress line every 200 ms. The line does not move until the very end. Explain the freeze and compare two ways to keep the progress line alive.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why did a timer due in 10 ms run only after a 300 ms loop had ended?
Each isolate runs one piece of code at a time on one thread, and its event loop hands out the turns: a callback waits until the running code ends.
-
When do microtasks run compared with timers and I/O events?
After main and after every event, the loop empties the microtask queue in order before it takes the next event, so a microtask always runs before a timer that is already waiting.
-
What goes in the event queue and what in the microtask queue?
The event queue holds outside work: timer callbacks, finished I/O and messages from other isolates. The microtask queue holds short follow-up jobs, such as scheduleMicrotask calls, and it has priority.
-
How do then, catchError and whenComplete treat a future that fails?
A future completes once, with a value or an error. then chains steps, an error skips them until a catchError, and whenComplete runs on both paths.
-
What happens to the error of a future that nobody handles?
It is reported as an unhandled exception and ends a command-line program with exit code 255, even after main has returned. Keep the future and chain catchError, or await it in a try.
-
What does a Completer do?
It turns a callback API into a future: return its future at once, then call complete(value) or completeError(error) from the callbacks. It completes only once; a second complete throws a StateError.
-
What do Future.syncValue and Future.pause do?
Future.syncValue(v), from Dart 3.10, never accepts another future as its value, unlike Future.value. Future.pause(d), from Dart 3.13, waits for d without running code and never completes with an error.
Key takeaways
- Each isolate runs one piece of code at a time on one thread, and its event loop hands out the turns:
mainfirst, then one waiting callback after another, each run to its end. - After
mainand after every event, the loop runs all waiting microtasks in order, so microtasks always come before the next timer, I/O or message event. - A future completes once, with a value or an error;
thenchains steps, an error skips them untilcatchError, andwhenCompleteruns on both paths. - A future’s error that nobody handles is reported as an unhandled exception and ends a command line program with exit
code 255, even after
mainhas returned. - A
Completerturns a callback API into a future, withcompleteandcompleteError, and it completes only once. - Long synchronous work holds up every timer and callback in its isolate; move it to another isolate.
Terms in this lesson
- Completer
- A dart:async object that creates a future and completes it later with complete(value) or completeError(error), often from the callbacks of an API that does not return futures. It completes only once.
- event loop
- The loop inside each Dart isolate that runs one piece of code at a time: main first, then each waiting microtask, then the next event, each to its end, until nothing is left to wait for.
- event queue
- The queue of outside work an isolate's event loop takes one item at a time: timer callbacks, finished I/O and messages from other isolates, in the order they arrived.
- future
- An object of type Future<T> that completes once, later, with a value of type T or with an error. Callbacks registered with then, catchError and whenComplete, or an await, receive the result.
- microtask
- A short follow-up job, such as a then callback of a completed future or a scheduleMicrotask call. The event loop runs every waiting microtask, in order, before it takes the next event.
References
- Concurrency in Dart (the event loop) (The Dart project (Google))
- Asynchronous programming (The Dart project (Google))
- Future class (The Dart project (Google))
- Future.pause static method (The Dart project (Google))
- Future.syncValue constructor (The Dart project (Google))
- scheduleMicrotask function (The Dart project (Google))
- Completer class (The Dart project (Google))
- Futures and error handling (The Dart project (Google))
- Dart SDK changelog (3.10.0 and 3.13.0) (The Dart project (Google))
Report a problem with this lesson
Kept only in this browser. Your Learn progress