Ordering bugs, frozen UIs and flaky tests come from misunderstanding when async callbacks actually run.
Each isolate runs one thread with an event loop and two queues:
- The microtask queue — short internal follow-ups:
scheduleMicrotask,Future.microtask, and completions of already-completed futures andawaitcontinuations. - The event queue — external events: timers (
Future.delayed,Timer,Future(...)), I/O, user input, messages from other isolates, and in Flutter, frame callbacks.
The loop runs the current synchronous code to completion, then drains the entire microtask queue, then takes one event, and repeats. So:
- Synchronous code is never interrupted. A long loop on the main isolate freezes the UI because no events (including frames) can run.
- Microtasks always run before the next timer, even
Future.delayed(Duration.zero). - Too many microtasks starve the event queue, delaying I/O and frames.
An async function runs synchronously up to its first await. When the awaited future completes, the rest of the function is scheduled as a microtask. await on a non-future value still yields to the microtask queue.
Future(() => ...) schedules on the event queue; Future.value(x).then(...) and Future.sync run the callback in a microtask or immediately. Zones (runZonedGuarded) carry context across async gaps — Flutter uses them to catch uncaught async errors.
For heavy CPU work, async doesn't help; move it to another isolate.
Example
import 'dart:async';
Future<void> work() async {
print('2 work starts (sync)');
await null; // yields to microtasks
print('5 work resumes (microtask)');
}
void main() {
print('1 main');
Future(() => print('8 event: Future()'));
Timer.run(() => print('9 event: Timer.run'));
Future.delayed(Duration.zero, () => print('10 event: delayed zero'));
scheduleMicrotask(() => print('4 microtask'));
work();
Future.value(0).then((_) => print('6 then on completed future'));
Future.microtask(() => print('7 Future.microtask'));
print('3 main ends');
}
// Output follows the numbers: sync code, then all microtasks
// in scheduling order, then events in order.Remember
- Sync code runs to completion; then all microtasks; then one event
awaitcontinuations andthenon completed futures are microtasks- Timers and
Future()go on the event queue, after all microtasks
Watch out for
- Doing heavy synchronous work in an async function and expecting it not to block the UI.