HomeDocumentationThe async nature of Dart
The async nature of Dart
13

The Event Loop: One Thread, Many Tasks

Dart Event Loop Explained — Microtasks, Events, and Why Order Matters

April 2, 2026

Run this code and predict the output before scrolling down:

dart
void main() {
  print('1');

  Future(() => print('2'));

  Future.microtask(() => print('3'));

  Future.delayed(Duration.zero, () => print('4'));

  scheduleMicrotask(() => print('5'));

  print('6');
}

The output:

javascript
1
6
3
5
2
4

If you got it right, you probably already understand the event loop. If you didn't — if you expected 2 to print before 3, or 4 to print immediately because the delay is zero — then the mental model you're using for Dart's async system is wrong in a specific, fixable way.

The order isn't random. It's not a race condition. It's deterministic, every single time, on every platform. And it falls out of one architectural fact: Dart runs your code on a single thread, and that thread is managed by an event loop with two queues that have different priorities.

One thread. Really.

When you write async/await in Dart, nothing runs in parallel. There are no background threads handling your Futures. There is one thread — the isolate's thread — and it executes one piece of code at a time, sequentially, with no preemption.

This surprises developers coming from languages with real threading. In Java, when you submit a task to an ExecutorService, it runs on a different thread, actually concurrently. In Go, goroutines are multiplexed across OS threads by the scheduler. In JavaScript — which Dart's async model most closely resembles — the same single-threaded model applies, and the same confusions exist.

The single thread means: if your code is running, nothing else is running. No timer callback, no Future completion, no tap handler. They're all waiting. They wait in queues, and the event loop processes those queues in a specific order.

The event loop

The event loop is a while(true) at the heart of every Dart isolate. Conceptually, it looks like this:

dart
// Pseudocode — this is what the Dart runtime does internally
void eventLoop() {
  while (true) {
    // 1. Run ALL pending microtasks
    while (microtaskQueue.isNotEmpty) {
      var task = microtaskQueue.removeFirst();
      task();
    }

    // 2. Run ONE event from the event queue
    if (eventQueue.isNotEmpty) {
      var event = eventQueue.removeFirst();
      event();
    }

    // 3. If both queues are empty, sleep until something arrives
    // (in Flutter, this is where epoll_wait blocks — see Android series Post 3)
  }
}

Two queues. Different priorities. The microtask queue is drained completely before the event loop even looks at the event queue. One microtask can schedule another microtask, and that microtask runs before any event. The event queue processes one event at a time, with a full microtask drain between each event.

This is the entire mechanism. Everything else is a consequence.

The two queues

The microtask queue is for short, high-priority work that needs to complete before the next event. Microtasks are scheduled by:

  1. `scheduleMicrotask(() =
  2. ...)`
  3. `Future.microtask(() =
  4. ...)`
  5. Internal VM operations (Future completion callbacks, in some cases)

When a Future completes, its .then() callback is scheduled as a microtask. This is important — it means Future completion callbacks have priority over new events.

The event queue is for everything else — the main work of the application:

  1. Timer callbacks (Future.delayed, Timer, Timer.periodic)
  2. I/O completions (file reads, network responses, from the OS via dart:io)
  3. `Future(() =
  4. ...) (which is shorthand for Timer.run`)
  5. In Flutter: VSync signals, tap events, platform channel responses

Events are the heartbeat of the application. Microtasks are the cleanup that happens between heartbeats.

Walking through the puzzle

Now the opening example makes sense. Let's trace it:

`main()` starts executing (this is itself an event — the initial event that starts the isolate):

  1. print('1') — runs immediately. Output: 1
  2. `Future(() =
  3. print('2')) — schedules print('2') on the **event queue** (it's Timer.run` under the hood)
  4. `Future.microtask(() =
  5. print('3')) — schedules print('3')` on the **microtask queue**
  6. `Future.delayed(Duration.zero, () =
  7. print('4')) — schedules print('4')` on the **event queue** (it's a timer, even with zero delay)
  8. `scheduleMicrotask(() =
  9. print('5')) — schedules print('5')` on the **microtask queue**
  10. print('6') — runs immediately. Output: 6

`main()` returns. The event loop regains control.

Step 1: Drain the microtask queue.

  • print('3') — Output: 3
  • print('5') — Output: 5
  • Microtask queue is empty.

Step 2: Process one event from the event queue.

  • print('2') — Output: 2

Back to step 1: Drain microtasks (none).

Step 2: Process one event.

  • print('4') — Output: 4

Final output: 1, 6, 3, 5, 2, 4. Deterministic. Every time.

Why zero-delay isn't instant

Future.delayed(Duration.zero, callback) doesn't mean "run immediately." It means "schedule a timer with a zero-millisecond timeout on the event queue." The callback still waits for:

  1. The current synchronous code to finish
  2. All pending microtasks to drain
  3. Any events already ahead of it in the event queue

Zero delay means "as soon as possible, through the event queue." Not "now." The distinction matters when you're trying to schedule work "after the current frame" in Flutter — Future.delayed(Duration.zero) and Future.microtask put work in different queues with different priorities.

In Flutter, this has a practical consequence:

dart
@override
void initState() {
  super.initState();

  // This runs before the first frame paints
  Future.microtask(() {
    // Microtask: runs during the microtask drain,
    // before the next event (which might be the VSync)
    print('microtask');
  });

  // This runs after the current event finishes + microtasks drain
  Future(() {
    // Event: queued behind the VSync signal
    // The first frame may have already painted
    print('event');
  });
}

When you need to do something "right after this build completes but before the frame paints," WidgetsBinding.instance.addPostFrameCallback is the correct tool — not Future.microtask or Future.delayed. But understanding why requires knowing which queue each mechanism uses.

What `await` actually does

await is not a blocking call. It does not pause the thread. It splits a function into two parts: the code before the await and the code after it. The code after becomes a callback that's scheduled when the awaited Future completes.

dart
Future<void> fetchUser() async {
  print('before');            // Runs synchronously
  final user = await api.getUser();  // Registers callback, RETURNS
  print('after: $user');      // Runs later, as a microtask, when getUser() completes
}

When execution reaches await api.getUser():

  1. The getUser() call starts (which might schedule an HTTP request as an I/O event).
  2. The await registers a callback: "when this Future completes, schedule the rest of fetchUser as a microtask."
  3. fetchUser() returns — right here, right now. It returns an incomplete Future<void> to its caller.
  4. The event loop continues processing other events and microtasks.
  5. Eventually, the HTTP response arrives as an I/O event. The Future from getUser() completes.
  6. The "rest of fetchUser" is scheduled as a microtask.
  7. On the next microtask drain, print('after: $user') runs.

This is why async/await doesn't block the UI in Flutter. When you await a network call, the function returns immediately, the event loop continues processing VSync events and tap handlers, and the continuation runs later when the data arrives. The single thread is never blocked — it's just doing other things while your network request is in flight.

The request itself is handled by the OS (the kernel's network stack), not by Dart. Dart's I/O library registers interest in the socket's file descriptor using epoll (Linux/Android) or kqueue (macOS/iOS), and the event loop picks up the completion when the kernel signals that data is available. (The Android series, Post 3 covers this mechanism in detail.)

Microtask starvation

Here's the dangerous consequence of the microtask queue's priority:

dart
void oops() {
  scheduleMicrotask(() {
    print('microtask 1');
    scheduleMicrotask(() {
      print('microtask 2');
      scheduleMicrotask(() {
        print('microtask 3');
        // ... and so on forever
      });
    });
  });

  // This event NEVER runs
  Future(() => print('event'));
}

If microtasks keep scheduling more microtasks, the microtask queue never empties, and the event queue never gets processed. In Flutter, this means no VSync events, no tap events, no frame rendering — the app freezes completely. The thread isn't blocked by a slow computation; it's running at full speed, draining microtasks endlessly.

This is called microtask starvation, and it's rare in practice because most code doesn't schedule microtasks directly. But it can happen subtly:

dart
Stream<int> infiniteStream() async* {
  int i = 0;
  while (true) {
    yield i++;
  }
}

void startListening() {
  infiniteStream().listen((value) {
    print(value); // Prints 0, 1, 2, 3, ... forever
    // The UI never updates because stream events are microtasks
  });
}

An async* generator that yields values synchronously (no await inside the loop) produces a stream that floods the microtask queue. Each yield schedules the listener callback as a microtask, and the synchronous loop immediately yields the next value. The event queue — and with it, your Flutter UI — starves.

The fix: introduce an await in the loop to yield control back to the event loop:

dart
Stream<int> infiniteStream() async* {
  int i = 0;
  while (true) {
    yield i++;
    await Future(() {}); // Yield to the event queue
  }
}

That await Future(() {}) schedules a no-op event and suspends the generator until it runs. This gives the event queue a chance to process VSync signals, tap events, and everything else between each batch of stream values.

The Flutter connection

In a Flutter app, the event loop is what drives everything. The engine schedules a VSync callback on the event queue for each frame. When the event loop picks it up:

  1. Animation ticks fire (updating AnimationController values)
  2. The build phase runs (calling build() on dirty widgets)
  3. Layout and paint run
  4. The frame is sent to the raster thread

Between frames, the event loop processes other events: tap handlers, network responses, timer callbacks. If your synchronous code (a build() method, a state computation) takes too long, it delays the next VSync event, and the frame is late — that's jank.

The frame budget at 60fps is 16.67ms. That's 16.67ms of event loop time: the VSync event plus all the microtasks it generates plus any inter-frame events. Exceed it and the user sees a stutter.

This is why "don't do heavy computation on the main isolate" is Flutter's most important performance rule. Not because Dart is slow — because the event loop is shared between your computation and the rendering pipeline. A 50ms JSON parse blocks the event queue for 50ms, which is three missed frames at 60fps.

The solution for heavy computation is Isolate.run — a separate isolate with its own event loop, running on a different OS thread. We'll cover that in Post 3.

`Timer` vs `Future` vs `scheduleMicrotask`

These three are the scheduling primitives. Everything else is built on top of them:

MechanismQueueWhen it runs
scheduleMicrotask(fn)MicrotaskBefore the next event
Future.microtask(fn)MicrotaskBefore the next event (returns a Future)
Future(fn)EventAfter current event + microtasks (same as Timer.run)
Future.delayed(duration, fn)EventAfter duration elapses + current event + microtasks
Timer(duration, fn)EventAfter duration elapses
Timer.periodic(duration, fn)EventRepeatedly, every duration
Future.value(x).then(fn)Microtaskfn runs as microtask (Future is already completed)

The choice between them is about priority:

  • Microtask: "Run this before anything else gets a chance." Use for short callbacks that complete a logical operation (Future chaining, state cleanup). Don't use for heavy work.
  • Event (Timer/Future): "Run this when the event loop gets around to it." Use for work that can wait — deferred initialization, throttled updates, anything that shouldn't block the current frame.

The real mental model

Forget "async means parallel." Forget "await pauses execution." Here's the model that actually predicts behavior:

  1. One thread runs one piece of code at a time. When your function is executing, nothing else is executing.
  2. `await` doesn't pause — it returns. The function gives up control, and the event loop continues. The rest of the function runs later, as a microtask, when the awaited Future completes.
  3. Two queues, strict priority. Microtasks always drain completely before the next event. Events process one at a time.
  4. I/O doesn't happen on your thread. Network, file, timer — the OS handles these. Dart just gets notified (via epoll/kqueue) when they're done, as events.
  5. If you block the thread, you block everything. Synchronous code that takes 100ms means 100ms of no events processed — no frames, no taps, no nothing.

This model explains every async behavior in Dart. The print-order puzzle. The UI freeze from heavy computation. The microtask starvation from an unbounded stream. The fact that Future.delayed(Duration.zero) runs after Future.microtask. All of it falls out of the event loop's two queues and the strict drain-microtasks-first rule.

The next post looks at what a Future actually is under the hood — a state machine with three states, and what happens inside the VM when you create one, complete one, and chain one.

This is Post 0 of the Async Dart series. Next: Futures: Promises With a Dart Accent.

Related Topics

dart event loopdart microtask queuedart event queueflutter event loopdart single threadeddart async internalsfuture.microtask vs future.delayeddart scheduleMicrotask

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.