Sparround

The event loop and microtask queue

A Dart isolate is single-threaded: only one piece of code runs at any moment. The illusion of concurrency comes from the event loop, which endlessly pulls work off queues and runs it.

There are two queues, and they have different priority:

  • The microtask queue — for short, internal work: scheduleMicrotask, Future.microtask, and then callbacks attached to an already-completed future.
  • The event queue — for external events: timers (including Future.delayed), Future(() {}), I/O, network responses, user events, isolate messages.

The rule of the loop fits in one sentence: the microtask queue is drained completely before a single item is taken from the event queue. Concretely:

1. The current synchronous code runs to the end (nothing can interrupt it). 2. The microtask queue is drained — and microtasks added during that drain run in the same pass. 3. One item is taken from the event queue and executed. 4. Back to step 2.

How you write itWhich queueWhen to use it
`scheduleMicrotask(fn)`MicrotaskShort internal work that must run right after the current synchronous block
`Future.microtask(fn)`MicrotaskThe same, but when you need the result as a `Future`
`completedFuture.then(fn)`MicrotaskOrdinary chaining — not a choice you make, it is how the language works
`Future(fn)` / `Future.delayed(Duration.zero, fn)`Event queue (timer)Deferring work and giving other events a turn
`Timer(d, fn)` / `Timer.run(fn)`Event queueTime-based work
I/O, network, isolate messagesEvent queueExternal sources — the system enqueues them, not you

`Future.microtask` vs `Future(() {})` vs `Future.delayed(Duration.zero)` — this trio is asked together:

  • Future.microtask(fn) — the microtask queue. Runs as soon as the current synchronous code ends, before any timer or I/O.
  • Future(fn) — lands on the event queue as a zero-duration timer. Runs after every microtask.
  • Future.delayed(Duration.zero, fn) — practically the same as Future(fn); both create a timer. The nuance: Future.delayed explicitly schedules a Timer and queues just after Future(fn) when both are scheduled together.

Why does this matter? Two real reasons:

1. Microtask starvation. Microtasks have absolute priority over the event queue. A microtask chain that re-schedules itself can keep timers, I/O and UI frames waiting forever. That is why microtasks are only for short work. 2. Blocking. A long synchronous computation drains no queue at all — the event loop stops. In Flutter that is literally a frozen UI: any synchronous work exceeding the 16 ms frame budget drops frames.

Note: await Future.delayed(Duration.zero) does not mean "wait a bit" — it merely lets already queued work run. It does nothing to lighten your own synchronous computation.

Predicting execution order — how to solve the whiteboard question. It always sounds like "what does this code print?". Derive the answer mechanically in five steps:

1. Write down the synchronous lines top to bottom — they all print first. 2. As you pass them, add every Future.microtask, scheduleMicrotask and then on a completed future to a microtask list, in scheduling order. 3. Add every Future(...), Future.delayed(...) and Timer(...) to an event list. 4. When the synchronous part ends, run the microtask list to exhaustion (anything added during the drain belongs here too). 5. Then run the event list one item at a time, draining the microtask list again after each one.

Two subtleties:

  • The part of an async function before its first `await` is synchronous — it runs immediately. Everything after the await is scheduled as a callback.
  • await someAlreadyCompletedFuture; still suspends the function and schedules the remainder as a microtask — the reasoning "it is already complete, so execution continues synchronously" is wrong.

Interview tip. On this topic the question nearly always arrives as an execution-order puzzle. The key to a strong answer is not guessing the output but narrating the process: "first the synchronous lines... these go to the microtask queue... this one is a timer, so it goes to the event queue...". The interviewer is looking for the model, not the answer.

One sentence must be said out loud: "the event queue is not touched until the microtask queue is completely empty". That is the key to every puzzle of this kind.

The three most common mistakes: (1) thinking await "pauses the program" — only that function pauses; (2) treating Future.delayed(Duration.zero) as a microtask — it is a timer; (3) forgetting that the part of an async function before its first await runs synchronously.

Finish with the practical consequence, which sets you apart: this knowledge is not academic — a long synchronous computation is a frozen UI in Flutter, and an unbounded microtask chain starves timers.

📚 Sources and documentation