Web Workers and Shared Memory
Why threads
Everything in Phases 1–3 shares one constraint: CPU-bound work blocks the main thread.
Parse a 5 MB CSV in a for loop and the page freezes — no clicks, no animation,
no render step. A worker moves that loop to another core. The trade: workers have
no DOM, no window — they compute and message, they do not touch the page.
Messages cross the boundary by structured clone — or by zero-copy transfer for buffers.
Message passing
Spawn, message, terminate
// main.js — browser context
const worker = new Worker("worker.js");
worker.postMessage({ job: "hash", payload: bigString }); // structured clone: copied
worker.onmessage = (event) => console.log("result:", event.data);
worker.onerror = (error) => console.error("worker failed:", error.message);
// worker.js — a different file, its own world
self.onmessage = (event) => {
const result = heavyCompute(event.data.payload); // blocks the WORKER, not the page
self.postMessage(result);
};
Everything crossing that boundary is copied (structured clone) — two heaps, two copies of your data. For a 16 MB buffer, the copy is the cost. Hence:
Transferables: moving instead of copying
Zero-copy handoff
// Browser context: hand over a 100MB buffer — no bytes are copied.
const buffer = new ArrayBuffer(100 * 1024 * 1024);
worker.postMessage(buffer, [buffer]); // second argument: the TRANSFER list
console.log(buffer.byteLength); // 0 — ownership MOVED; main thread can no longer read it
Transferring changes ownership, not contents: after the move, the sender's
reference is dead. For large buffers this turns a linear-time copy into a constant-time
pointer handoff. Demo async/30_workers_lab.js benchmarks both paths with Node's
worker_threads.
Shared memory and Atomics
SharedArrayBuffer: one heap, two threads
// Main thread: create shared memory and share it BY REFERENCE.
const shared = new SharedArrayBuffer(8);
const counter = new Int32Array(shared); // typed-array view over the bytes
worker.postMessage(shared); // no copy, no transfer — BOTH see it
// Worker thread — unsynchronized increment is a RACE:
// counter[0]++; // read, add, write — three steps, another thread can
// // interleave between them. Lost updates, silently.
// The fix: Atomics read-modify-write as ONE indivisible operation.
Atomics.add(counter, 0, 1); // safe: atomic increment
Atomics.store(counter, 0, 5);
Atomics.load(counter, 0); // read without tearing
Atomics.wait / Atomics.notify go further — a worker can sleep
until another thread changes a value, the building block of locks and queues. (For
context-management reasons, Atomics.wait is not allowed on the main thread.)
Worker pools
Spawning a worker per task wastes its startup cost; a pool keeps N workers alive
and queues jobs onto them — the same shape as a connection pool. One worker per core is the
usual sizing. Demo practice/34_worker_pool_sample.js builds a minimal pool.
Practice: parallel versus serial
The task
Run async/30_workers_lab.js with node. Predict the speedup for two
CPU-bound blocks before running. Then add a third block and re-predict — with more workers
than blocks, what changes? Finally, verify the transfer demonstration: the main thread's
byteLength after handing over the buffer.
The checklist
- You can say what a worker cannot touch (DOM, window) and why it exists (CPU-bound work).
- You know messages are cloned by default and buffers can move by transfer instead.
- You can explain why unsynchronized
counter[0]++on shared memory is a race. - You reach for a pool, not a worker per task.