Event loop investigation
One call stack, two queues and a rule about when the browser is allowed to breathe.
- Level
- ENGINEER
- Read time
- 12 min
- Experiment
- Available
- Type
- SIMULATION
The question
Synchronous code, microtasks and tasks all end up running on the same thread. What decides the order, and where exactly does rendering fit?
Hypothesis
The loop drains the call stack, then drains the entire microtask queue, then may render, then takes exactly one task from the task queue, and repeats.
Method
Laboratory available
The instrument for this investigation runs in the laboratory, where the controls, the live model and the observation log share one workstation.
Enter the laboratoryLoad a program into the bench and step through it one tick at a time. The call stack, microtask queue and task queue are all visible; nothing is hidden behind an abstraction.
Protocol
- 01Run the 'Classic ordering' program and predict the output before stepping.
- 02Run 'Microtask starvation' and watch the task queue never get a turn.
- 03Run 'await is a microtask', note that everything after an await is a continuation, not a callback.
- 04Run 'Render opportunity' and observe where the frame can actually be painted.
What we observed
console.log("A");Promise.resolve().then(() => console.log("B"));setTimeout(() => console.log("C"), 0);console.log("D");// A D B CA and D are synchronous. B is a microtask, drained the moment the stack empties. C is a task, and tasks only run on the next turn of the loop, after every microtask, including microtasks queued by other microtasks.
Why it happens
One turn of the loop (simplified from the HTML standard)
- 01Take one task from a task queue and run it to completion.
- 02Drain the microtask checkpoint: run microtasks until the queue is empty, including newly queued ones.
- 03If this is a rendering opportunity: run animation frame callbacks, then style, layout, paint.
- 04Repeat.
function starve() { Promise.resolve().then(starve); // never yields}starve();// setTimeout(starve, 0) does the opposite:// it yields between every call, so the page keeps painting.| API | Queue |
|---|---|
| Promise .then/.catch/.finally, await continuation | Microtask |
| queueMicrotask | Microtask |
| MutationObserver callback | Microtask |
| setTimeout / setInterval | Task |
| DOM event dispatch from user input | Task |
| fetch response handling | Task, then microtasks for the promise chain |
| requestAnimationFrame | Neither, it runs in the render step, before style/layout |
| requestIdleCallback | Runs when the browser has spare time in a frame |
Why do microtasks exist at all?
So that a promise callback runs before the browser does anything else observable, including painting.
Further research
setTimeout(fn, 0) does not mean zero. The HTML standard requires a minimum of 4ms once a timer has been nested five levels deep, and browsers throttle timers heavily in background tabs. If you need to yield to rendering, requestAnimationFrame (before the next paint) or a MessageChannel port message (a task with no clamping) are the better instruments.
References
- 01HTML StandardEvent loops, processing model
- 02Jake ArchibaldTasks, microtasks, queues and schedules
- 03MDNIn depth: microtasks