JavaScript Web Workers Table
| Piece | What it does | Field note |
|---|---|---|
new Worker('w.js') | Spawn a real thread | Own event loop, own globals - heavy work stops freezing the page |
postMessage(data) | Send a message | STRUCTURED CLONE by default - sent copies, not shared references |
onmessage | Receive in worker/main | Both sides have postMessage/onmessage - the only shared door |
Transferable objects | Zero-copy handoff | ArrayBuffer moves by transfer - sender loses it (fast, one-way) |
self-importScripts / ESM | Load code in worker | { type: 'module' } unlocks imports inside the worker |
NO DOM in workers | document is undefined | No window, no elements - pure computation plus fetch/IndexedDB |
worker.terminate() | Kill from outside | Hard stop - no cleanup inside; design messages to finish first |
OffscreenCanvas | Even graphics move | Paint in a worker - the main thread keeps 60fps while pixels render |
A Web Worker is a REAL thread with its own event loop and globals - the fix for every long task that freezes the page: JSON parsing of huge payloads, image processing, search over big datasets, anything over about 50ms that steals frames from the main thread.
Bottom line: communication is message-passing with STRUCTURED CLONE - postMessage copies the data, so a mutation after send is invisible to the other side (not a shared reference, a delivered package). And workers see NO DOM: no document, no elements - pure computation plus fetch and IndexedDB.
The honest part: workers are not free. Spawning costs tens of milliseconds, messages copy (unless you transfer), and the message-passing architecture turns simple code into protocol code. The threshold that pays: work that blocks the main thread long enough to drop frames - not a loop that takes 5ms.
How to use
- Offload the heavy step: const w = new Worker('parse.js'); w.postMessage(bigJson) - the page keeps 60fps while the worker chews.
- Hand off buffers zero-copy: w.postMessage(buffer, [buffer]) - the ArrayBuffer TRANSFERS (sender loses it), skipping the clone entirely for big payloads.
- Keep the protocol tidy: one message type object ({type, payload}) both directions, and finish work before terminate() - it is a hard stop with no cleanup.
Frequently asked questions
What does structured clone actually copy - and what does it refuse?
It deep-copies plain data: objects, arrays, Map, Set, Date, RegExp, ArrayBuffer and typed arrays - more than JSON survives (undefined, NaN, Map and Set all make it), and circular references work, which JSON.stringify cannot do. It refuses functions, DOM nodes, and Error prototypes degrade - the things that cannot cross a thread boundary meaningfully. The practical rule: postMessage delivers a deep copy with richer type support than JSON, but it is still a COPY - two clones of one object do not stay synchronized, which is why SharedArrayBuffer exists for the rare true-sharing case.
Why would I transfer an ArrayBuffer instead of just posting it?
Because cloning a 100MB buffer means copying 100MB; transferring moves OWNERSHIP in O(1) - the second postMessage argument lists transferables, and the buffer's bytes never copy, but the sender's reference is NEUTERED (byteLength 0). It is a move, not a share. The pattern: read a file into a buffer on the main thread, transfer it to the worker for processing, transfer the result back - two zero-copy hops around the expensive computation. The footgun: using the buffer after transferring throws or reads nothing, so treat transferred references as dead immediately.
What can a worker NOT do, and how do I design around it?
Workers have no DOM: no document, no elements, no window - so they cannot touch the page, read layout, or draw directly. The design is split-brain by intention: the worker computes (parse, transform, search, hash), the main thread renders the result. Also absent: localStorage (synchronous, main-thread-tied - use IndexedDB, which IS available in workers), and some APIs like alert. Modern additions keep shrinking the gap: fetch works, OffscreenCanvas moves painting into the worker, and module workers ({ type: 'module' }) allow real imports - but the DOM line stands.
When is a worker the wrong tool for a slow operation?
When the cost is I/O latency, not CPU, or the workload is shorter than the worker's spawn cost. A slow fetch is already off-thread - async/await on the main thread handles it, and a worker adds protocol overhead for nothing. A computation that takes 5-20ms does not justify tens of milliseconds of worker startup plus clone time; debounce it or chunk it instead. The honest threshold: repeated or occasional work that measurably drops frames (long tasks over ~50ms in the performance panel) - that is what workers exist to eliminate, and the panel before-and-after is the proof.