Core Idea
The two panels above run the same animation, and the button hands both the same 700 ms of arithmetic — the only difference is which thread does it. Left runs the job on the main thread: the instant it starts, the animation stops, scroll stops, clicks land on a page that cannot answer. Nothing “slowed down” — everything stopped, together, because the main thread is one queue with one worker, and a busy loop starves everything queued behind it. Right posts the job to a Web Worker — a real OS-level thread the browser spins up on request, with its own event loop, its own heap, and no DOM. Same work, same duration; the page never feels it. And where the browser supports it, the right panel is being painted by the worker itself through OffscreenCanvas, which is why it keeps gliding straight through a freeze that stops everything else on this page.
A Thread With No Window
A worker is not a lighter event loop bolted onto yours — it is a separate thread with a separate global scope (self, not window). What it lacks defines it: no DOM, no layout, no styles, not even access to the page’s variables. A worker cannot move an element or paint a pixel of the page; it can only compute, fetch, and talk. What it has is the valuable part: its own event loop, so a 200 ms job in a worker blocks only that worker’s queue — and its own heap, so even its garbage-collector pauses are its own problem, not your frame’s.
That isolation is also why the API is message-shaped. Two threads sharing one object graph would need locks, and locks in the hands of every web page is a catastrophe the platform deliberately declined. Instead, threads share nothing by default, and everything crosses a bridge.
The Bridge Is a Copy
postMessage looks like handing an object over. It is not — it is structured clone: the browser walks your object graph and rebuilds a deep copy on the other side. For a config object, that’s nothing. For the kind of data 3D work moves — a Float32Array of ten thousand particle positions, a decoded mesh, a physics snapshot — the copy is itself main-thread work, paid on every send. Offloading a computation and then spending milliseconds cloning its inputs across is a real way to lose the race you were trying to win.
The escape from copying is the transferable: an ArrayBuffer, ImageBitmap, or OffscreenCanvas can be moved instead of cloned — postMessage(msg, [buf.buffer]) — and ownership changes hands in O(1), no matter the size. The proof that nothing was copied is what happens to the sender: its reference is neutered, byteLength reads zero, the memory is simply gone from this thread. The language enforces the handoff because the alternative — two threads mutating one buffer with no locks — is the exact race the whole design exists to prevent. This is one more place where flat typed arrays beat object soup: an object graph can only ever be cloned, but a Float32Array can be handed over for free.
One honest footnote: true shared memory does exist. SharedArrayBuffer lets two threads see the same bytes, coordinated with Atomics — no copies, no transfers, real parallel access. The price is paperwork: after Spectre it only exists on pages served with COOP/COEP isolation headers, and correct lock-free code is its own discipline. It is the right tool for a physics engine streaming state every frame; it is overkill for almost everything else.
Rendering, Off the Thread
The default split is: worker computes, main thread paints. OffscreenCanvas removes even that. Call transferControlToOffscreen() on a canvas, post the result to a worker (it’s a transferable), and the worker owns the pixels — it can hold a 2D or WebGL context and run its own requestAnimationFrame loop, entirely off the main thread. That inverts the failure mode this article opened with: a render loop that survives main-thread jank. The page can hitch on layout, a third-party script, a click handler that does too much — and the canvas keeps animating, because its frames were never in that queue. The hero above is the demonstration: during the 700 ms freeze, the off-thread panel doesn’t drop a frame it owes you.
The practical architecture that falls out is a division of labor: DOM, input, and UI state on the main thread; simulation and rendering in a worker; typed-array transfers or a SharedArrayBuffer as the seam between them.
The Judgment Call
Workers cure exactly one disease: CPU-bound stalls on the main thread. If your frame is late because the GPU is drowning in fragment work, a worker changes nothing — the CPU vs GPU diagnosis comes first, always. But when the diagnosis is CPU — a 10 ms simulation step, pathfinding, geometry decompression, image decoding — the arithmetic is decisive: moving 10 ms of sim off the main thread hands the entire 16.6 ms budget back to rendering, and the sim can even run wider than one frame without anyone noticing. The cost is structural, not computational: your code acquires an async seam, and everything crossing it must be copy-cheap, transferred, or shared on purpose.
Connections
A worker is the event loop article’s moral applied twice: the main thread stalls because it is one queue — so buy a second queue. What may cross between the queues is governed by buffers — structured clone for object graphs, O(1) transfer for typed arrays, SharedArrayBuffer when both sides truly need the same bytes. Workers pair naturally with WebAssembly: the heavy, allocation-free inner loop compiles to WASM, the worker gives it a thread that can’t hurt the frame, and the results travel home as transferables. And the discipline is the same one garbage collection teaches — decide where memory lives and who owns it, and the platform rewards you; let every frame improvise, and you pay in stutter, on whichever thread you earned it.