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:noneparagraph as a normal box. The code rangetComputedStylein a microtask, before the station was attached to the document, and computed style is empty for disconnected elements. The fix waits untilsample.isConnected. A Playwright test now asserts that the render tree dropsp[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 fromnode --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-syntaxprintsfalsefor{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).