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.