Charts · 08

The event loop, for interfaces

Every stutter, every spinner that never appears, every click that seems to do nothing for a moment is the same thing: the browser runs one task at a time on the thread that also paints. Start with what that does to an interface, with real work and real frames. Then the rules underneath, stepped through: the microtask queue drains before the next task, rAF runs in the render step, and painting only happens between tasks.

What it does to an interface

longest frame gap in the last ~1.5 s: 0 ms (smooth)

1 · The label that never shows

Sets the text to “Saving…” then does 400 ms of work in the same task. You never see “Saving…”: the browser paints after the task ends, by which time it says “Saved”.

2 · The fix: let it paint first

Same work, but after requestAnimationFrame then setTimeout, so a frame paints “Saving…” before the work starts. The loading state exists because the loop was given a turn.

3 · Two animations while you click

CSS transform animation
JavaScript, one frame at a time

Click either button above and watch: the CSS animation keeps moving, because the compositor runs it off the main thread; the JavaScript one stops dead until the task ends. Which is why transform and opacity animations survive a busy page and everything else does not.

The rules underneath, step by step

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

Call stack

  1. script

Microtask queue · drains completely before the next task

  1. empty

Task queue · setTimeout, events, I/O

  1. empty

rAF callbacks · run in the render step

  1. empty
Render: stylelayoutpaintcomposite

Console

    1 / 9: the script runs as one task

    The script is one task. Microtasks (the promise) drain the moment that task's stack empties, before any other task, so 3 prints before the setTimeout's 2 even at 0 ms.

    Notes

    The demos do real work: a synchronous loop for the chosen number of milliseconds on the main thread, and the frame meter measures the gaps between requestAnimationFrame callbacks, so a dropped frame is a measured one. The label demo shows the single most common loading-state bug in the wild: state set and heavy work in the same task, so the state is never painted. The fix is to give the loop a turn (a frame, then a task) before the work, or, better, move the work off the thread with a Worker or split it.

    The model implements the HTML specification’s processing model in miniature: run a task to completion, perform a microtask checkpoint, then, if there is a rendering opportunity, run animation frame callbacks and update the rendering, then take the next task. It treats a 0 ms timer as due before the next frame, which is what happens in practice (frames are about 16 ms apart and the timer clamp is 1 to 4 ms), and ignores task sources and priorities, which do not change these orderings. Sources: WHATWG HTML, section 8.1.7 Event loops; Jake Archibald, Tasks, microtasks, queues and schedules (2015) and In the loop (JSConf.Asia 2018); Philip Roberts, What the heck is the event loop anyway? (JSConf EU 2014).