skip to content

A Flutter screen freezes although no single Dart callback is slow; how can the microtask queue starve the event queue, and how do you fix it?

level: seniorimportance: nice to knowfreq 24%

answer

  1. chain length, not callback length
  2. drain runs until queue empty
  3. await on completed value is a microtask
  4. yield with a zero-duration timer
  5. Future.pause in Dart 3.13

basics

~20 s

The event loop drains every microtask, including newly added ones, before taking any event, so an unbroken microtask chain blocks timers, input and frames. Break it by yielding through a zero-duration timer every N items, or move heavy work to an isolate.

solid answer

~40 s

Each isolate's event loop empties the microtask queue before it takes the next event, and microtasks scheduled during the drain join the same drain. A chain of individually tiny microtasks therefore holds back every timer, I/O callback and, on Flutter's UI isolate, frame and input callback until it ends. Common sources are recursive `scheduleMicrotask` or `Future.microtask`, and loops that `await` already-completed futures or plain values, because each such continuation is itself a microtask: `async` code does not yield unless the awaited future is genuinely pending. The fix is to yield through the event queue in batches, with `await Future<void>.delayed(Duration.zero)` or, since Dart 3.13, `await Future.pause()`, to replace self-rescheduling microtasks with `Timer.run`, and to move CPU-heavy work to another isolate.

code

dart · 22 lines
dart
import 'dart:async';

// Starves: each await on an already-completed future resumes as a microtask,
// so no timer, input or frame callback runs until the loop ends.
Future<int> sumCached(List<Future<int>> cached) async {
  var total = 0;
  for (final f in cached) {
    total += await f;
  }
  return total;
}

// Yields: every 500 items the loop waits on a zero-duration timer,
// so events queued meanwhile get a turn before it continues.
Future<void> indexAll(List<String> items, void Function(String) index) async {
  for (var i = 0; i < items.length; i++) {
    index(items[i]);
    if (i % 500 == 499) {
      await Future.pause(); // Dart 3.13+; else Future<void>.delayed(Duration.zero)
    }
  }
}

go deeper

for a junior

Recall that microtasks all run before the next event, so an endless chain of them means no timer or tap is ever handled.

for a middle

Explain why awaiting an already-completed future creates a microtask continuation, and why Future.delayed(Duration.zero) or Future.pause yields while Future.microtask does not.

for a senior

Diagnose from a profile showing one long task of tiny async steps, then choose between batching with a timer yield, removing needless awaits, and moving the work to an isolate.

for a principal

Set team guidance: reserve microtasks for work that must precede the next event, make yielding explicit in long loops, and budget UI-isolate work per frame.

## The symptom A Flutter screen stops responding: taps do nothing, animations stall, a spinner freezes. A profile shows no single expensive function. Instead there is one long, uninterrupted stretch on the UI isolate made of thousands of tiny asynchronous steps. This is **event-queue starvation by the microtask queue**. ## Why short callbacks can still freeze everything Each Dart isolate runs one event loop with two queues. After any piece of code finishes, the loop runs **every** pending microtask, including microtasks scheduled by the microtasks it is running, and only when that queue is empty does it take the next event. Timer callbacks, I/O completions, isolate messages and, in Flutter's root isolate, the engine's frame, input and platform-channel callbacks all have to wait for that drain to end. So the length of a microtask *chain* matters, not the length of any one microtask. The `scheduleMicrotask` API documentation carries exactly this warning: a function that keeps rescheduling itself with `scheduleMicrotask` means a `Timer.run` callback is never executed. ## A minimal reproduction ```dart import 'dart:async'; void main() { Timer.run(() => print('timer')); var n = 0; void step() { if (++n < 1000000) scheduleMicrotask(step); } step(); print('scheduled'); } // 'scheduled' prints first; 'timer' prints only after // all 999,999 rescheduled microtasks have run. ``` Every `step` is trivial, yet the zero-duration timer, created before any of them, cannot fire until the chain ends. Remove the upper bound and it never fires at all. On Flutter's UI isolate the same shape blocks the next frame and every tap for as long as the chain lasts. ## Ways teams create a chain without noticing 1. **Recursive `scheduleMicrotask`.** A "process one item, then schedule the rest" loop that uses `scheduleMicrotask` or `Future.microtask` never yields to events. 2. **`await` on values that are already complete.** Every `await` suspends, but when the awaited future has already completed, or the value is not a future at all, the continuation is scheduled as a **microtask**. A loop that awaits cached futures, `Future.value(...)` or synchronous results thousands of times is one long microtask chain. 3. **Long `.then` chains on completed futures.** Each late listener is notified in a microtask, and each callback that returns another completed future extends the chain. The trap in cases 2 and 3 is the belief that `async`/`await` makes code "yield". It only yields when the awaited future is genuinely pending on an event. ## Diagnosing it - In a profile, look for one very long task on the UI thread whose stack is dominated by microtask dispatch and async continuations rather than by one heavy function. - Check whether pending timers fire late all at once when the chain ends; a `Timer.periodic` heartbeat that logs its drift makes this obvious. - Search the hot path for `scheduleMicrotask`, `Future.microtask`, and `await` inside loops whose futures are usually already complete. ## Fixes | Fix | When it fits | |---|---| | Yield to the event queue every N items with `await Future<void>.delayed(Duration.zero)` | Work that is cheap per item but long in total | | `await Future.pause()` (Dart 3.13+) | Same as above, without a dummy computation | | Replace self-rescheduling `scheduleMicrotask` with `Timer.run` or `Future()` | Background loops that must let input through | | Drop the `await` on synchronous or cached results | Code that awaited out of habit | | Move the work to another isolate | CPU-heavy work whose total time exceeds a frame budget even when chunked | Yielding via a zero-duration timer works because the continuation now sits in the **event queue**, behind the input and frame work that was waiting, rather than at the head of the microtask queue. ## Judgement calls - **Batch, do not yield per item.** A timer per item adds scheduling overhead; yielding every few hundred items or every few milliseconds keeps overhead low while the UI stays responsive. - **Yielding is not parallelism.** Chunking keeps the UI responsive but the total work still runs on the UI isolate's single thread. If the total is large, an isolate is the real fix. - **Keep microtasks for what must precede the next event**, such as finishing a state update before any input is processed. Everything else belongs in the event queue.

  • Why does await Future.delayed(Duration.zero) yield while await Future.value(x) does not?
    `Future.delayed` completes from a zero-duration `Timer`, which is an event, so the continuation waits behind events already queued. `Future.value(x)` completes in a microtask and its continuation is another microtask, so it runs before any event and the chain never breaks.
  • Is chunking with timers enough for a large JSON-heavy computation?
    Only if the total work fits comfortably between frames. Chunking keeps input and frames flowing but every chunk still runs on the UI isolate's single thread, so the total cost still competes with rendering. For large CPU work, running it on another isolate is the real fix.

saying these in an interview costs you the question

  • Making a loop async with await automatically lets the UI update between iterations.
  • Only a single long-running callback can block the event loop.
  • The event loop cuts off the microtask drain after a time limit.
  • Wrapping each step in Future.microtask gives input events a turn.
  • Chunking work with timers runs it in parallel with rendering.