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.