Frontend Labs
⋯

81. Station walkthrough: rendering, the event loop and V8

Which stages run?

Each button animates the same box for 1.5 seconds by changing one property per frame in a requestAnimationFrame loop, and lights up the pipeline stages that property triggers:

Property JS Style Layout Paint Composite
left ● ● ● ● ●
background-color ● ● ● ●
transform (own layer via will-change) ● ● ●

The station measures frame times, and tells you to turn on DevTools → Rendering → "Paint flashing" to see the difference: left and background-color flash green every frame, transform never does.

Layout thrashing, measured

// Interleaved: every read forces a layout, because the previous write invalidated it
for (const b of bars.children) {
  const w = b.offsetWidth;          // READ  → forced synchronous layout
  b.style.width = `${w + 1}px`;     // WRITE → layout dirty again
}
// Batched: one layout total
const widths = [...bars.children].map((b) => b.offsetWidth);
[...bars.children].forEach((b, i) => (b.style.width = `${widths[i] + 1}px`));

On the test machine, with 400 bars: 118 ms interleaved, 1.2 ms batched, about 100× faster for the same reads and writes. The Playwright suite asserts that interleaved is slower on every run.

Bug found while building it. The DOM-vs-render-tree panel first showed the display:none paragraph as a normal box. The code ran getComputedStyle in a microtask, before the station was attached to the document, and computed style is empty for disconnected elements. The fix waits until sample.isConnected. A Playwright test now asserts that the render tree drops p[hidden] and includes ::before.

The event-loop simulator, verified against the real engine

This is the lab's centerpiece. Each snippet exists twice: as real code, and as a list of operations the simulator executes with the HTML event-loop rules:

{
  id: "basics",
  run: (console) => {                         // the REAL code
    console.log("script start");
    setTimeout(() => console.log("timeout"), 0);
    Promise.resolve()
      .then(() => console.log("promise 1"))
      .then(() => console.log("promise 2"));
    console.log("script end");
  },
  ops: [                                     // the MODEL of it
    { at: '"script start"', log: "script start" },
    { at: "setTimeout", timeout: { delay: 0, label: "timeout cb", body: [{ log: "timeout" }] } },
    { at: '.then(() => console.log("promise 1"))', then: { label: "then: promise 1", body: [{ log: "promise 1" }],
      next: { label: "then: promise 2", body: [{ log: "promise 2" }] } } },
    { at: '"script end"', log: "script end" },
  ],
}

The code panel shows sourceOf(snippet.run), which uses Function.prototype.toString() to return the function's exact source text. The displayed code can't drift from the code that runs. Each op's at is a substring of its line, so the simulator can highlight the line that is executing.

The simulator (lib/loop-model.js) is about 120 lines of plain logic. It runs the script as the first task, drains microtasks whenever the stack empties (including microtasks queued meanwhile), moves expired timers to the task queue in (delay, insertion) order, and records a snapshot after every step:

function drainMicrotasks() {
  if (!S.micro.length) return;
  snap("checkpoint", "The call stack is empty: microtask checkpoint. Drain the WHOLE microtask queue, including microtasks queued along the way.");
  while (S.micro.length) {
    const m = S.micro.shift();
    S.stack.push(m.label);
    snap("run-micro", `Run microtask "${m.label}".`);
    exec(m.body);
    S.stack.pop();
    if (m.next) {                     // a .then() chain: the next reaction is queued only now
      S.micro.push(m.next);
      snap("micro", `"${m.label}" returned, which resolves the next promise in the chain: "${m.next.label}" is queued.`);
    }
  }
}
// `await x` (x already settled) suspends the function: the rest of its body
// becomes ONE microtask (the one-tick await, §14).

The UI is a set of boxes (call stack, Web APIs, microtask queue, task queue, console) whose tokens animate in and out as you step. Kept tokens are patched in place rather than re-inserted, because re-inserting a DOM node restarts its CSS animation. The first version re-rendered every box on every step, and every token flickered.

"Run it for real" runs snippet.run with a recording console, waits for the timers, and compares the result with the model's output. Playwright clicks it for every snippet on every run. Model and engine agree on all four snippets, including the subtle one:

a: before await → b runs → sync → a: after await → then → timeout 0ms → timeout 10ms

await b() on an async function that has already returned takes one microtask tick, so a's continuation is queued before the Promise.resolve().then(...) that appears later in the source. Before 2019's await optimization it took three ticks, and "then" printed first. If an engine ever changes this, the test catches it. The quiz has you predict the order before you step through: a fun way to find out what you really believe about the event loop.

Inside V8, honestly labelled

The Engine station mixes real data and illustration, and says which is which:

  • Real: the Ignition bytecode for add(a, b) comes from node --print-bytecode (Ldar a1 / Add a0, [0] / Return, where [0] is the feedback slot that records the operand types). The megamorphic benchmark is real: 20M property reads over objects with 1 shape vs 8 shapes, run in two separate function bodies so they don't share type feedback.
  • Illustrative: the tier-up thresholds (Ignition → Sparkplug → Maglev → TurboFan) and the hidden-class tree, which models V8's Map transitions. The panel tells you how to verify it: %HaveSameMap(a, b) with --allow-natives-syntax prints false for {x, y} vs {y, x}. The same check was run while writing this chapter.

Pressing "Call add('oops', 1)" after optimization plays a deopt: the optimized code assumed small integers, a string broke the assumption, and V8 dropped back to the interpreter (§49).

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36