Frontend Labs
⋯

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:

  1. 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.
  2. 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.
  3. 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.

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