Frontend Labs
⋯

82. Station walkthrough: threads and storage

Jank you can see

A canvas ball bounces with requestAnimationFrame, under a live frame-time graph: green bars meet the 16.7 ms budget, orange ones miss it, red ones are long frames. Counting primes below 6 million (trial division, about 1 second) on the main thread freezes the ball, and the "worst frame gap" readout jumps to about 1,000 ms. The same function in a module worker leaves the ball smooth:

// workers/primes.js — the same import, a different thread
import { countPrimes } from "../lib/primes.js";
self.onmessage = ({ data: { limit } }) => {
  const t0 = performance.now();
  self.postMessage({ count: countPrimes(limit), ms: performance.now() - t0 });
};

// station
const w = new Worker(new URL("../workers/primes.js", import.meta.url), { type: "module" });
w.postMessage({ limit });

The first version counted primes below 800,000. That took 41 ms, which was too fast to make a visible point. Demos need the cost to be felt, so the options are now 3M, 6M and 12M.

A data race, then Atomics

With cross-origin isolation on, four workers each increment one shared Int32Array slot a million times:

// workers/counter.js
self.onmessage = ({ data: { sab, n, atomic } }) => {
  const counter = new Int32Array(sab);
  if (atomic) for (let i = 0; i < n; i++) Atomics.add(counter, 0, 1);   // one indivisible RMW
  else for (let i = 0; i < n; i++) counter[0]++;                         // load, add, store: interleavable
  self.postMessage("done");
};

A real run: counter[0]++ gave 1,055,785 of 4,000,000, so 2,944,215 updates were lost. Atomics.add gave exactly 4,000,000. It was slower (126 ms vs 18 ms), and that cost is the price of correctness. Once this happens on screen, data races stop being abstract.

Copy vs transfer posts a 64 MB ArrayBuffer both ways. In the test run, structured clone blocked the main thread for 12 ms and transfer for 1.5 ms. After the transfer, the sender's byteLength is 0 (§38). Copy cost grows with size; transfer cost doesn't.

IndexedDB, event by event

Every request and transaction event is logged with a timestamp as it fires: open → upgradeneeded → success, transaction(readwrite) → add → success → complete. The pitfall button shows the IndexedDB rule that catches everyone (§45):

const tx = db.transaction("notes", "readwrite");
const store = tx.objectStore("notes");
await sleep(10);                                     // not an IDB request…
store.add({ text: "never written" });                // TransactionInactiveError: the transaction auto-committed

A transaction stays alive only while it has pending requests at the end of a task. Awaiting a timer or a fetch ends it. The "Sync vs async storage" button writes 2 MB to each store: localStorage blocks the main thread for the whole write, while IndexedDB returns almost immediately and does its I/O elsewhere.

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