The event loop and microtasks

Dart · Expert · 14 min

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 and await continuations.
  • 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
  • await continuations and then on 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.

Check yourself

  1. Which prints first: a `Future.delayed(Duration.zero, ...)` callback or a `scheduleMicrotask` callback scheduled after it?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: dart.dev/language/concurrency