83. Station walkthrough: Core Web Vitals and debugging
Measuring vitals the way web-vitals does
lib/vitals.js starts observers at page load with buffered: true, so
entries from before the module loaded are delivered too:
// CLS: shifts are grouped into "session windows" (gap < 1s, window < 5s);
// CLS is the LARGEST window. Shifts within 500ms of input don't count.
observe("layout-shift", (entries) => {
for (const e of entries) {
if (e.hadRecentInput) continue;
const first = windowEntries[0], last = windowEntries.at(-1);
if (last && e.startTime - last.startTime < 1000 && e.startTime - first.startTime < 5000) {
windowValue += e.value;
windowEntries.push(e);
} else {
windowValue = e.value;
windowEntries = [e];
}
vitals.CLS = Math.max(vitals.CLS, windowValue);
}
});
// INP: group event entries by interactionId, keep the slowest per interaction,
// then take the worst (or the 98th percentile once there are 50+ interactions).
observe("event", (entries) => { /* … */ }, { durationThreshold: 16 });
Breaking CLS taught three lessons while building it:
- A banner inserted when you click doesn't count, because shifts within
500 ms of input are excluded (
hadRecentInput). The lab's "ad" arrives 800 ms later, as real ads do. - The first version inserted the banner below the fold. Shifts of off-screen content don't count either, so CLS stayed at 0. The banner now lands at the top of the station, above the gauges.
- One 120 px banner scored only about 0.055 to 0.086, because a single
shift's score is limited by how far and how much of the viewport moved.
The real-world pattern scores higher: the ad arrives, then resizes
itself 400 ms later. Two shifts in one session window add up. The real
run measured
0.063 + 0.064 = 0.128, which is "needs improvement".
Breaking INP: both buttons do 350 ms of work. One blocks:
const slowClick = (e) => {
e.currentTarget.textContent = "Working… (350ms blocking)";
blockFor(350); // no paint can happen until this returns
e.currentTarget.textContent = "Slow handler (350ms)";
};
The other yields every 10 ms:
const yieldToMain = () => globalThis.scheduler?.yield ? scheduler.yield() : new Promise((r) => setTimeout(r, 0));
const yieldingClick = async (e) => {
e.currentTarget.textContent = "Working in chunks…";
for (let i = 0; i < 35; i++) {
blockFor(10);
await yieldToMain(); // the browser can paint after the first chunk
}
e.currentTarget.textContent = "Same work, yielding";
};
INP measures from the input to the next paint. In the test run, the slow
handler's interaction measured 384 ms ("poor"). The yielding one painted
after its first 10 ms chunk and measured 16 ms, even though the total
work is identical. scheduler.yield() (Chrome 129+) is better than
setTimeout(0) because its continuation is prioritized ahead of other
queued tasks, so other code can't slip in and delay the rest of your
work. The Long
Animation Frames table shows which script and invoker made each slow frame
slow. That attribution is what you need to fix INP in production.
The debugging station
lib/errors.js installs the listeners every error tracker uses, at page
load:
addEventListener("error", (e) => record("error", { message: e.message, where: `${e.filename}:${e.lineno}:${e.colno}`, stack: e.error?.stack }));
addEventListener("unhandledrejection", (e) => record("unhandledrejection", { message: String(e.reason?.message ?? e.reason) }));
addEventListener("securitypolicyviolation", (e) => record("csp", { message: `${e.effectiveDirective} blocked ${e.blockedURI || "inline"}` }));
new ReportingObserver((reports) => { /* deprecations, interventions, CSP reports */ }, { buffered: true }).observe();
The buttons throw from a timer callback, reject a promise nobody handles,
and run new Function("return 1") under a CSP without 'unsafe-eval'.
Each one appears in the captured list ([csp] script-src blocked eval @ debug.js:61), and the Playwright suite asserts all three.
The memory leak you can find. "Leak 10k nodes" creates 10,000 <div>s,
never attaches them, and keeps them in a module-level array. They are
detached DOM nodes, the most common leak in single-page apps (§50). The
station tells you how to find them: DevTools → Memory → Heap snapshot →
filter "Detached" → select a HTMLDivElement → Retainers leads to the
array named leaked in debug.js. "Measure memory" calls
performance.measureUserAgentSpecificMemory(). It is only available
because the page is cross-origin isolated, and it waits for a garbage
collection, so it can take a few seconds.
User Timing. "Run measured work" wraps two phases in
performance.mark and performance.measure. The detail.devtools field
puts the measures on a custom "Browser Lab" track in Chrome's Performance
panel, which is how you make your own app's phases visible next to the
browser's.