Web Workers and Shared Memory

Workers are the exception from lesson 19: JavaScript's one concession to real parallelism. Each worker is a full thread with its own loop and heap. This lesson covers messaging, zero-copy transfer, and what shared memory makes possible — and dangerous.

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.

Main thread and worker thread exchanging messages

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.