Frontend Labs
⋯

Part XV — Capstone II: Browser Lab, a live animated tour of the browser

Capstone I (§52) used the platform to build an app. Capstone II turns the platform into the subject. Browser Lab is a web app that shows, with live data and animation, what your browser does: how a page arrives over TLS and HTTP/2, how the rendering pipeline runs, how the event loop orders work, what V8 does with your code, how threads, IndexedDB and Core Web Vitals behave, and how to debug all of it. It is built with JavaScript and CSS only: no framework, no build step, no runtime dependencies. About 2,500 lines, including its own Playwright suite.

It has nine stations, each with missions that tick themselves off when you do the thing ("Make INP poor, then make it good with yielding"), so the guide's concepts become something you do, not just read.

Station What's live Guide chapters
00 The big picture process/thread map + animated "life of a click"; your browser's capabilities §18, §22
01 URL to bytes your page's real Navigation Timing; a request burst over HTTP/1.1 vs HTTP/2; TLS 1.3 vs 1.2 handshake; negotiated ALPN/cipher; SSE §25–§30
02 Rendering which pipeline stages left/background-color/transform trigger; layout thrashing measured; DOM vs render tree §21–§22
03 Event loop step-through simulator of stack and queues, prediction quiz, and a check against the real engine §12–§15
04 Inside V8 tier-up and deopt animation, real bytecode, hidden-class tree, inline-cache states, megamorphic benchmark §49
05 Threads jank you can see, a worker that fixes it, copy vs transfer, a SharedArrayBuffer data race fixed by Atomics §37–§42
06 Storage IndexedDB events as they fire, the auto-commit pitfall, sync vs async storage cost §43–§45
07 Core Web Vitals live LCP/INP/CLS/FCP/TTFB gauges; break CLS and INP and fix them; Long Animation Frames §35
08 Debugging captured errors, rejections and CSP violations; a findable memory leak; User Timing; DevTools recipes §50, §51

79. Architecture and setup

cd JS/projects/browser-lab
npm run cert        # one-time: self-signed cert for localhost (or use mkcert for no warning)
npm start           # http://localhost:8088  → HTTP/1.1
                    # https://localhost:8443 → HTTP/2 over TLS 1.3
npm install && npx playwright install chromium
npm test            # 46 tests (23 per protocol), run over BOTH protocols

The full source is on the companion page Browser Lab — full source and in the wiki at JS/projects/browser-lab/.

browser-lab/
├── server.mjs                 node:http (HTTP/1.1) + node:http2 (h2 over TLS), COOP/COEP/CSP, 3 API routes
├── scripts/cert.mjs           openssl → certs/{cert,key}.pem
├── public/
│   ├── index.html             shell: sidebar + <main id="stage">
│   ├── css/lab.css            plain CSS, custom properties, light/dark, all animations
│   └── js/
│       ├── main.js            router: hash → dynamic import("./stations/<id>.js")
│       ├── lib/dom.js         h() element builder, formatting, storage helpers
│       ├── lib/missions.js    mission list + progress + toasts (localStorage)
│       ├── lib/vitals.js      CWV observers, started at page load
│       ├── lib/errors.js      global error/rejection/CSP capture, started at page load
│       ├── lib/loop-model.js  the event-loop simulator (pure functions, no DOM)
│       ├── lib/primes.js      the CPU-heavy work shared by main thread and worker
│       ├── stations/*.js      one module per station: export function mount() → { el, unmount }
│       └── workers/*.js       module workers: primes, shared counter, echo
└── tests/lab.spec.js          Playwright, projects "http1" and "h2"

One server, two protocols

// server.mjs
http.createServer(handler).listen(HTTP_PORT, HOST);

if (existsSync(CERT) && existsSync(KEY)) {
  // allowHTTP1: browsers that don't offer "h2" in ALPN still get a page.
  http2
    .createSecureServer({ cert: readFileSync(CERT), key: readFileSync(KEY), allowHTTP1: true }, handler)
    .listen(HTTPS_PORT, HOST);
}

The same handler serves both. Node's HTTP/2 compatibility API gives it req/res objects that look like HTTP/1's. Browsers only speak HTTP/2 over TLS, and they pick it through ALPN during the handshake (§26). That's why the lab needs a certificate to show HTTP/2 at all.

War story from building it. The first certificate was a P-256 EC key made with macOS's built-in LibreSSL. Node's own HTTP/2 client accepted it, but Chromium failed with ERR_SSL_PROTOCOL_ERROR, and so did curl. RSA-2048 worked everywhere. When TLS fails only in some clients, suspect the certificate and key format before your code. Tools like mkcert exist for exactly this reason.

Headers that unlock features

const HEADERS = {
  "Content-Security-Policy":
    "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; " +
    "worker-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
  "Cross-Origin-Opener-Policy": "same-origin",       // + COEP = crossOriginIsolated (§39):
  "Cross-Origin-Embedder-Policy": "require-corp",    //   SharedArrayBuffer, measureUserAgentSpecificMemory
  "Cross-Origin-Resource-Policy": "same-origin",
  "X-Content-Type-Options": "nosniff",
  "Referrer-Policy": "no-referrer",
};

The strict CSP is part of the lesson. The lab has no inline scripts and no eval, and the Debugging station triggers a violation on purpose.

A router in 25 lines, with code splitting

// main.js
async function route() {
  const id = location.hash.replace(/^#\/?/, "") || "overview";
  const meta = STATIONS.find((s) => s.id === id) ?? STATIONS[0];
  renderNav(meta.id);
  unmount?.();                                            // stop the old station's timers, rAF loops, observers
  unmount = null;
  const t0 = performance.now();
  const mod = await import(`./stations/${meta.id}.js`);  // fetched on first visit only (§16)
  const view = await mod.mount({ meta, loadMs: performance.now() - t0 });
  stage.replaceChildren(view.el);
  unmount = view.unmount ?? null;
  document.title = `${meta.title} · Browser Lab`;
  stage.focus({ preventScroll: true });                   // move focus for keyboard and screen-reader users
  performance.mark("station-shown", { detail: { station: meta.id } });  // shows in DevTools' Timings track
}
addEventListener("hashchange", route);

Every station follows the same contract: mount() returns { el, unmount }. unmount is the most important line in an SPA without a framework. The Threads station runs a requestAnimationFrame loop, and without unmount it would keep running in the background after you leave, a leak that §83's heap snapshot would find.

lib/vitals.js and lib/errors.js are imported at startup, not when their stations open. Core Web Vitals and errors have to be observed from page load, or they miss the events that matter (§83).

The h() builder and missions

All DOM is built by one 15-line helper. Text goes in as text nodes, so the whole app is XSS-safe by construction (§65):

export function h(tag, attrs = {}, ...children) {
  const el = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs ?? {})) {
    if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
    else if (k === "style" && typeof v === "object") Object.assign(el.style, v);   // CSSOM: allowed by style-src 'self'
    else if (v === true) el.setAttribute(k, "");
    else if (v !== false && v != null) el.setAttribute(k, String(v));
  }
  for (const c of children.flat(Infinity)) {
    if (c != null && c !== false) el.append(c instanceof Node ? c : document.createTextNode(String(c)));
  }
  return el;
}

Setting el.style.width through the CSSOM is allowed under style-src 'self'. Only inline style="…" attributes and <style> tags are blocked. That's why the lab can animate freely under a strict CSP.

Missions persist in localStorage, wrapped in try/catch because storage can throw in private modes. A station calls missions.complete("vitals", "cls") when the condition becomes true, and the sidebar badges and progress bar re-render through a tiny subscription.

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