Web Workers
JavaScript on a page runs on a single thread — the main thread. That one thread parses your script, runs your event handlers, computes layout, and paints frames. When a function on it takes 200ms, nothing else on the page happens for those 200ms: clicks queue up, animations stall, the cursor keeps spinning. The page is janky.
The browser aims to produce a frame every ~16.7ms to hit 60fps. Any single task that runs longer than that budget blocks a frame from being drawn. Sort a million rows, parse a 40MB JSON file, run image filters in a loop — do any of that on the main thread and the UI freezes until it’s done.
A Web Worker is the escape hatch. It runs a separate JavaScript file on a genuinely separate OS thread, in parallel with the main thread. It has its own event loop, its own global scope, its own memory. The heavy loop runs over there while the main thread stays free to keep the page at 60fps. The two threads don’t share variables — they talk by passing messages.
This isn’t cooperative concurrency like the event loop gives you, and it isn’t async/await either — those still run everything on one thread, just interleaved. A worker is real parallelism: two threads executing at the same instant on different CPU cores.
Creating a worker
You point the Worker constructor at a script URL. That script runs in the new thread as soon as it loads.
// main.js
const worker = new Worker("worker.js");
By default this creates a classic worker — the script runs as a plain top-level script, and to pull in dependencies you use the synchronous importScripts():
// worker.js (classic)
importScripts("./math-utils.js", "https://cdn.example.com/lib.js");
The modern option is a module worker, which runs the file as an ES module. Now import and export work exactly as they do elsewhere, top-level await is allowed, and the script is loaded with the strict, deferred semantics of modules:
// main.js
const worker = new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
name: "sorter", // shows up in DevTools; handy when you run several
});
// worker.js (module)
import { quicksort } from "./sort.js";
self.onmessage = (e) => {
postMessage(quicksort(e.data));
};
Inside the worker there is no window. The global object is a DedicatedWorkerGlobalScope, and you refer to it as self. Calling postMessage(...) inside the worker is really self.postMessage(...) — it sends to the main thread, the one that created this worker.
The message channel
Communication is symmetric and asynchronous. One side calls postMessage(data); the other side receives a message event whose .data property holds a copy of what was sent.
// main.js
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
worker.onmessage = (e) => {
console.log("worker replied:", e.data);
};
worker.postMessage({ cmd: "sum", numbers: [1, 2, 3, 4] });
// worker.js
self.onmessage = (e) => {
const { cmd, numbers } = e.data;
if (cmd === "sum") {
const total = numbers.reduce((a, b) => a + b, 0);
postMessage({ result: total });
}
};
Both onmessage and addEventListener("message", ...) work; use addEventListener when you want more than one listener. Note the ordering carefully — postMessage never blocks and never returns a value. It queues the message and returns immediately. The reply arrives later as its own event, on a future turn of the receiver’s event loop.
Because there’s no return value, most worker code invents a little protocol on top of postMessage: a cmd field to say what to do, or an id field to match a reply to the request that caused it. We’ll wrap that up neatly at the end.
Structured clone: how data crosses the boundary
The data you pass to postMessage is not shared — it’s copied using the structured clone algorithm. This is the same algorithm behind structuredClone(), IndexedDB storage, and history.pushState. It walks the object graph and rebuilds an independent copy on the other side.
It handles far more than JSON. It copies:
- primitives (except
Symbol), plain objects, and arrays Date,RegExp(thoughlastIndexis not preserved)Map,SetArrayBuffer,DataView, and every typed arrayBlob,File,FileList,ImageData,ImageBitmap- cyclic references — an object that points back at itself clones fine, which is something
JSON.stringifychokes on
What it cannot copy is just as important:
- Functions and class instances-as-behavior → throws
DataCloneError - DOM nodes → throws
DataCloneError - getters, setters, and other property descriptors — only the plain data values survive
- the prototype chain — a copied
class Userinstance arrives as a plain object;instanceof Userisfalseon the other side and its methods are gone
postMessage runs this exact algorithm, so you can watch it work right here on the main thread with structuredClone() — the same function, same rules. Clone a rich object and the copy is fully independent; hand it something with a function inside and it throws the same DataCloneError a worker would.
Transferable objects: zero-copy handoff
Some objects are backed by a raw block of memory: an ArrayBuffer, a MessagePort, a ReadableStream, an OffscreenCanvas, an ImageBitmap. For these you can transfer ownership instead of copying. The underlying memory is handed from one thread to the other — a pointer moves, no bytes are copied — so it’s fast and constant-time regardless of size.
You pass a second argument to postMessage: an array of the objects to transfer.
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
// ...fill it...
worker.postMessage({ pixels: buffer }, [buffer]);
// ^^^^^^^^ the transfer list
The catch, and it’s the whole point to internalize: a transferred object is detached (or “neutered”) on the sender. After the call, the sender’s reference still exists but points at nothing. Reading it throws or reports zero length.
const buf = new ArrayBuffer(8);
console.log(buf.byteLength); // 8
worker.postMessage(buf, [buf]);
console.log(buf.byteLength); // 0 ← detached, memory now belongs to the worker
You can watch that detachment happen without a worker at all: structuredClone accepts the very same transfer list, and moving a buffer through it neuters the original exactly as postMessage does. Run this and note how the sender’s byteLength collapses to 0 the instant ownership moves.
Typed arrays are serializable but not transferable — you can’t transfer a Uint8Array directly, but you can transfer its .buffer:
const view = new Uint8Array(1024);
worker.postMessage(view, [view.buffer]); // send the view, transfer its buffer
// after this, view.byteLength === 0 on this side
A worked example: moving work off the main thread
Here’s the failure mode, made concrete. This handler sorts a big array and blocks the whole page while it does:
// ❌ on the main thread — freezes the UI for the duration
button.addEventListener("click", () => {
const data = makeBigArray(5_000_000);
data.sort((a, b) => a - b); // hundreds of ms, main thread stuck
render(data);
});
While that sort runs, the spinner you started won’t spin, hover states won’t update, and the click you make on “Cancel” just sits in the queue. Move the sort into a worker and the main thread is free the entire time.
// main.js
const worker = new Worker(new URL("./sort-worker.js", import.meta.url), {
type: "module",
});
button.addEventListener("click", () => {
const data = makeBigArray(5_000_000); // a Float64Array
spinner.start();
// transfer the buffer so there's no 40MB copy
worker.postMessage(data, [data.buffer]);
});
worker.onmessage = (e) => {
spinner.stop();
render(e.data); // the sorted array comes back
};
// sort-worker.js
self.onmessage = (e) => {
const arr = e.data; // a Float64Array, transferred in
arr.sort((a, b) => a - b); // runs on the worker thread
postMessage(arr, [arr.buffer]); // transfer it back
};
The sort still takes just as long in wall-clock time — a worker doesn’t make the algorithm faster. What changes is where it runs. The main thread posts the work and immediately goes back to painting frames; the spinner animates smoothly; the result arrives as an event when it’s ready.
What workers can and can’t touch
A worker has no DOM. There is no document, no window, no parent, no access to page elements — by design, because the DOM is not thread-safe and letting two threads mutate it would be chaos. If a worker computes something the page must show, it posts the result back and the main thread touches the DOM.
But a worker is far from crippled. It gets a genuine, useful runtime:
fetch()andWebSocketfor network I/O- IndexedDB for storage
setTimeout/setInterval,queueMicrotask,structuredCloneBlob,FileReader,TextEncoder/TextDecoder,URLcrypto.subtle(Web Crypto),WebAssemblyimportScripts()in classic workers;importin module workersOffscreenCanvas, so you can even render graphics off-thread
Errors, and shutting down
Uncaught errors inside the worker don’t crash your page; they surface on the main thread as an error event. Listen for it.
worker.onerror = (e) => {
console.error(`Worker error: ${e.message} at ${e.filename}:${e.lineno}`);
e.preventDefault(); // stop it from also logging to the console
};
// separately, module-loading failures for import() etc. surface here:
worker.onmessageerror = (e) => {
// fired when an arriving message couldn't be deserialized
};
messageerror is the one people forget: it fires when a message arrives but can’t be deserialized on the receiving side — for instance if you accidentally send something the structured clone algorithm rejects in a context that surfaces it there.
When you’re done with a worker, stop it. A worker thread lives until you tell it to die (or the page unloads); leaving idle workers around wastes memory and a thread.
worker.terminate(); // from the main thread — kills it immediately, no cleanup
// from inside the worker, to shut itself down:
self.close();
terminate() is abrupt — it does not let pending work finish or run any teardown. If the worker holds resources that need flushing, have it do that and call self.close() itself, or post a “done, safe to terminate” message first.
Beyond one worker: pools
Spinning up a fresh worker per task gets expensive — each one has real startup cost (a new thread, a new realm, re-parsing the script). If you fire many short jobs, that overhead dominates.
The standard answer is a worker pool: create N workers once (often navigator.hardwareConcurrency of them, matching the CPU’s logical cores), keep them alive, and hand each incoming job to a free worker. Tasks queue when all workers are busy. It’s the same idea as a database connection pool, applied to threads.
Rather than hand-roll the request/reply plumbing every time, a tiny wrapper turns the event-based channel into a promise you can await:
function callWorker(worker, payload, transfer = []) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const onMessage = (e) => {
if (e.data.id !== id) return; // not our reply, ignore
worker.removeEventListener("message", onMessage);
e.data.error ? reject(new Error(e.data.error)) : resolve(e.data.result);
};
worker.addEventListener("message", onMessage);
worker.postMessage({ id, payload }, transfer);
});
}
// now heavy work reads like any async call:
const sorted = await callWorker(worker, bigArray, [bigArray.buffer]);
The id matches each reply to its request, so multiple in-flight calls don’t get their answers crossed. Libraries like Comlink formalize this further — they use Proxy to make a worker’s exported functions callable as if they were local async functions, hiding postMessage entirely. Worth reaching for once your protocol grows past a couple of message types.
Summary
- The main thread runs your JS, your events, and your rendering. A task longer than ~16.7ms blocks a frame and makes the page janky. A Web Worker runs a separate script on a real parallel thread to keep that from happening.
- Create one with
new Worker(url)(classic) ornew Worker(url, { type: "module" })for ESMimport. Usenew URL("./w.js", import.meta.url)for the path so bundlers resolve it. Module workers are Baseline widely available as of late 2025. - The two threads share no memory. They communicate with
postMessage(data)and amessageevent;postMessagenever blocks and never returns a value. - Data is copied by the structured clone algorithm — it handles dates, maps, sets, typed arrays, blobs, and cycles, but throws on functions and DOM nodes and drops prototypes, getters, and setters.
- Transferable objects (
ArrayBuffer,MessagePort, streams,OffscreenCanvas,ImageBitmap) can be moved with a transfer list for zero-copy handoff — but they’re detached on the sender afterward. - Workers have
fetch,WebSocket, IndexedDB, timers, Web Crypto, and WebAssembly — but no DOM,window, orlocalStorage. Post results back and let the main thread touch the page. - Handle failures with the
errorandmessageerrorevents; stop a worker withterminate()(from outside) orself.close()(from inside). - For many small jobs, use a worker pool and wrap
postMessagein a promise (or use Comlink) so calls read like ordinaryawait.