Frontend Labs
⋯

public/js/stations/threads.js

// Station 5: workers and threads (§37–§42, §82).
import { h, station, panel, button, ms, bytes } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";
import { countPrimes } from "../lib/primes.js";

const worker = (name) => new Worker(new URL(`../workers/${name}.js`, import.meta.url), { type: "module" });

// A bouncing ball drawn every frame, plus a frame-time graph. If the main
// thread is blocked, both freeze: that is jank, made visible.
function ballPanel() {
  const canvas = h("canvas", { width: 720, height: 200, "aria-label": "Animated ball and frame-time graph", role: "img" });
  const ctx = canvas.getContext("2d");
  const fps = h("b", {}, "—");
  const worst = h("b", {}, "—");
  const frames = [];
  let x = 40, y = 60, vx = 4, vy = 3, last = performance.now(), raf = 0, worstGap = 0;
  const color = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#3355ff";
  const tick = (now) => {
    const dt = now - last;
    last = now;
    frames.push(dt);
    if (frames.length > 180) frames.shift();
    worstGap = Math.max(worstGap, dt);
    x += vx * (dt / 16.7);
    y += vy * (dt / 16.7);
    if (x < 14 || x > 706) vx = -vx, (x = Math.max(14, Math.min(706, x)));
    if (y < 14 || y > 110) vy = -vy, (y = Math.max(14, Math.min(110, y)));
    ctx.clearRect(0, 0, 720, 200);
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.arc(x, y, 12, 0, Math.PI * 2);
    ctx.fill();
    // frame-time graph: one bar per frame, red when over the 16.7ms budget
    frames.forEach((f, i) => {
      ctx.fillStyle = f > 50 ? "#e53935" : f > 17.5 ? "#f5a623" : "#2eb872";
      const hgt = Math.min(70, f);
      ctx.fillRect(i * 4, 200 - hgt, 3, hgt);
    });
    ctx.fillStyle = "#888";
    ctx.fillRect(0, 200 - 16.7, 720, 1);
    fps.textContent = `${Math.round(1000 / (frames.slice(-30).reduce((a, b) => a + b, 0) / Math.min(30, frames.length)))}`;
    worst.textContent = ms(worstGap);
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return {
    el: h("div", {}, canvas, h("div", { class: "readouts" },
      h("div", { class: "readout" }, h("small", {}, "frames / second"), fps),
      h("div", { class: "readout" }, h("small", {}, "worst frame gap"), worst))),
    resetWorst: () => (worstGap = 0),
    worst: () => worstGap,
    stop: () => cancelAnimationFrame(raf),
  };
}

function jankPanel(ball) {
  const limit = h("select", { "aria-label": "How much work" },
    h("option", { value: "3000000" }, "primes below 3M (~0.3s)"),
    h("option", { value: "6000000", selected: true }, "primes below 6M (~1s)"),
    h("option", { value: "12000000" }, "primes below 12M (~3s)"));
  const out = h("ol", { class: "log", "aria-label": "Results" });
  const lanes = h("div", { class: "lanes" },
    h("div", { class: "lane" }, h("span", {}, "Main thread"), h("div", { class: "rail", "data-lane": "main" })),
    h("div", { class: "lane" }, h("span", {}, "Worker thread"), h("div", { class: "rail", "data-lane": "worker" })));
  const mark = (lane, startPct, widthPct) => {
    const rail = lanes.querySelector(`[data-lane="${lane}"]`);
    rail.replaceChildren(h("div", { class: `work ${lane === "worker" ? "worker" : ""}`, style: { left: `${startPct}%`, width: `${widthPct}%` } }));
  };
  let blockedOnMain = false;
  const onMain = () => {
    ball.resetWorst();
    out.prepend(h("li", {}, "main thread: computing… (the ball will freeze)"));
    mark("main", 5, 90);
    lanes.querySelector('[data-lane="worker"]').replaceChildren();
    // Let the message paint first, then block.
    setTimeout(() => {
      const t0 = performance.now();
      const count = countPrimes(Number(limit.value));
      const took = performance.now() - t0;
      out.prepend(h("li", {}, `main thread: ${count.toLocaleString()} primes in ${ms(took)}. The page could not paint or respond for that long.`));
      requestAnimationFrame(() => requestAnimationFrame(() => {
        blockedOnMain = ball.worst() > 100;
        out.prepend(h("li", {}, `worst frame gap: ${ms(ball.worst())}`));
      }));
    }, 50);
  };
  const inWorker = () => {
    ball.resetWorst();
    const w = worker("primes");
    mark("worker", 5, 90);
    mark("main", 5, 1);
    const t0 = performance.now();
    w.postMessage({ limit: Number(limit.value) });
    w.onmessage = ({ data }) => {
      out.prepend(h("li", {}, `worker: ${data.count.toLocaleString()} primes in ${ms(data.ms)} (round trip ${ms(performance.now() - t0)}), worst frame gap ${ms(ball.worst())}. The ball never stopped.`));
      w.terminate();
      if (blockedOnMain && ball.worst() < 100) missions.complete("threads", "jank");
    };
  };
  return panel("Experiment: the same work on two threads",
    h("div", { class: "row" }, limit, button("Run on the main thread", onMain, { class: "danger" }), button("Run in a Web Worker", inWorker, { class: "primary" })),
    lanes, out);
}

function transferPanel() {
  const out = h("ol", { class: "log", "aria-label": "Transfer results" });
  const run = async (transfer) => {
    const w = worker("echo");
    const buffer = new ArrayBuffer(64 * 1024 * 1024);
    const t0 = performance.now();
    w.postMessage({ buffer }, transfer ? [buffer] : []);
    const posted = performance.now() - t0;
    const size = await new Promise((r) => (w.onmessage = (e) => r(e.data)));
    out.prepend(h("li", {},
      `${transfer ? "transfer" : "copy (structured clone)"}: postMessage took ${ms(posted)} on the main thread; worker got ${bytes(size)}; sender's buffer is now ${bytes(buffer.byteLength)}`));
    w.terminate();
  };
  return panel("Copy vs transfer a 64 MB buffer",
    h("p", {}, "postMessage copies by default. Listing the buffer as transferable moves ownership instead: no copy, and the sender's buffer is detached (length 0)."),
    h("div", { class: "row" }, button("Copy it", () => run(false)), button("Transfer it", () => run(true), { class: "primary" })), out);
}

function racePanel() {
  const out = h("ol", { class: "log", "aria-label": "Race results" });
  const run = async (atomic) => {
    if (!crossOriginIsolated) {
      out.prepend(h("li", {}, "SharedArrayBuffer needs cross-origin isolation (COOP + COEP headers). Serve the lab with npm start."));
      return;
    }
    const sab = new SharedArrayBuffer(4);
    const N = 1_000_000;
    const workers = Array.from({ length: 4 }, () => worker("counter"));
    const t0 = performance.now();
    await Promise.all(workers.map((w) => new Promise((r) => ((w.onmessage = r), w.postMessage({ sab, n: N, atomic })))));
    const value = new Int32Array(sab)[0];
    workers.forEach((w) => w.terminate());
    const lost = 4 * N - value;
    out.prepend(h("li", {}, `${atomic ? "Atomics.add" : "counter[0]++"}: expected ${(4 * N).toLocaleString()}, got ${value.toLocaleString()} → ${lost.toLocaleString()} updates lost (${ms(performance.now() - t0)})`));
    raceState[atomic ? "atomicOk" : "lost"] ||= atomic ? lost === 0 : lost > 0;
    if (raceState.lost && raceState.atomicOk) missions.complete("threads", "race");
  };
  const raceState = {};
  return panel("Experiment: a data race on shared memory",
    h("p", {}, "Four workers each add 1 to the same counter a million times, through one SharedArrayBuffer. ", h("code", {}, "counter[0]++"), " is load, add, store: two threads can load the same value and one increment vanishes."),
    h("p", { class: "note" }, `crossOriginIsolated = ${crossOriginIsolated}`),
    h("div", { class: "row" }, button("Race with counter[0]++", () => run(false), { class: "danger" }), button("Fix with Atomics.add", () => run(true), { class: "primary" })), out);
}

export function mount() {
  const ball = ballPanel();
  return {
    el: station(
      "Workers & threads",
      "Your JavaScript, style, layout and paint share one main thread. Any long computation there freezes everything. Workers are real OS threads with their own event loop; they talk to the page by message passing or, with care, shared memory.",
      missionList("threads"),
      panel("The main thread's heartbeat", ball.el, h("p", { class: "note" }, "Green bars fit the 16.7ms frame budget, orange ones miss it, red ones are long frames.")),
      jankPanel(ball),
      h("div", { class: "grid-2" }, transferPanel(), racePanel()),
    ),
    unmount: ball.stop,
  };
}

Generated from SOURCE.md · 8 Oct 2026 16:36