Web Workers: Getting Work Off the Main Thread Without the Pain
JavaScript on the main thread does everything: running your code, computing styles, laying out the page, painting, and handling input. There is one thread for all of it, so a function that takes 400 milliseconds does not merely delay its own result — it freezes scrolling, blocks clicks, and stalls animations for those 400 milliseconds.
Breaking work into chunks with setTimeout or scheduler.yield() keeps the page responsive but does not make the work faster; it interleaves. A Web Worker is the only mechanism that runs JavaScript genuinely in parallel, on a separate thread, while the main thread keeps painting.
The Cost Model You Have to Respect
Workers do not share memory with the main thread. Everything crossing the boundary is copied using the structured clone algorithm, and that copy is real work performed on the sending thread. This single fact determines whether a worker helps or hurts.
// COUNTERPRODUCTIVE: the copy costs more than the work saved. Sending a
// 50 MB array to compute a sum means serialising 50 MB on the main thread -
// the exact thing you were trying to avoid.
worker.postMessage({ type: 'sum', values: fiftyMegabyteArray });
// WORTHWHILE: send parameters, not data. The worker fetches and processes,
// and only a small result comes back.
worker.postMessage({ type: 'analyse', url: '/data/readings.csv', threshold: 0.8 });The rule that follows: a worker pays off when the work is large relative to the data crossing the boundary. Parsing a large file the worker fetches itself is ideal. Summing an array the main thread already holds is usually not.
Three shapes of task are reliably worth it:
- Work on data the worker can obtain itself — fetch, IndexedDB, or the Origin Private File System.
- Long-running computation on small inputs — cryptography, image generation, pathfinding, simulation.
- Anything using transferable objects, where the data moves rather than being copied.
Transferable Objects: Moving Instead of Copying
The exception to the copying cost. An ArrayBuffer, MessagePort, ImageBitmap, or stream can be transferred: ownership moves to the receiving thread in constant time, and the sender loses access.
const pixels = new Uint8ClampedArray(width * height * 4);
fillWithImageData(pixels);
// The second argument lists buffers to transfer rather than copy.
// Note we transfer the underlying .buffer, not the typed array view.
worker.postMessage({ type: 'filter', pixels, width, height }, [pixels.buffer]);
// The sender's view is now DETACHED - length 0, reading gives nothing.
console.log(pixels.length); // 0
console.log(pixels.buffer.byteLength); // 0
// So transfer LAST, after you have finished with the data. Transferring
// then reading is a genuinely confusing bug: no error, just empty data.// filter-worker.js - transfer the result back the same way.
self.onmessage = ({ data }) => {
const { pixels, width, height } = data;
for (let i = 0; i < pixels.length; i += 4) {
const grey = pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114;
pixels[i] = pixels[i + 1] = pixels[i + 2] = grey;
}
self.postMessage({ pixels, width, height }, [pixels.buffer]);
};For data both threads need simultaneously, SharedArrayBuffer gives genuinely shared memory with no transfer at all. It requires cross-origin isolation headers, which is a real deployment cost:
# Both headers are required, and they will break third-party embeds that # are not themselves CORS-enabled. Check what you load before enabling. Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp
Making Workers Readable
Raw postMessage produces code nobody enjoys maintaining: a message type enum, a switch statement, and correlation IDs to match responses to requests. Wrapping it in a small promise-based RPC layer costs about forty lines and removes all of that.
// worker-rpc.js
export function createWorkerRpc(scriptUrl, { type = 'module' } = {}) {
const worker = new Worker(scriptUrl, { type });
const pending = new Map();
let nextId = 0;
worker.addEventListener('message', ({ data }) => {
const entry = pending.get(data.id);
if (!entry) return; // response to a cancelled call
pending.delete(data.id);
if (data.error) {
// Rebuild a real Error - a structured clone of an Error loses its
// prototype, so the stack and name have to be reattached by hand.
const error = new Error(data.error.message);
error.name = data.error.name;
error.stack = data.error.stack;
entry.reject(error);
} else {
entry.resolve(data.result);
}
});
// A worker-level error rejects everything in flight, rather than leaving
// callers hanging on promises that will never settle.
worker.addEventListener('error', (event) => {
const failure = new Error('Worker failed: ' + event.message);
for (const { reject } of pending.values()) reject(failure);
pending.clear();
});
function call(method, args = [], transfer = []) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, method, args }, transfer);
});
}
return { call, terminate: () => worker.terminate() };
}// analysis-worker.js - just export functions; the harness handles plumbing.
const methods = {
async parseCsv(url) {
const text = await fetch(url).then((r) => r.text());
return text.trim().split('\n').slice(1).map((line) => {
const [id, value, at] = line.split(',');
return { id: Number(id), value: Number(value), at };
});
},
summarise(rows) {
const values = rows.map((r) => r.value).sort((a, b) => a - b);
return {
count: values.length,
median: values[Math.floor(values.length / 2)],
p95: values[Math.floor(values.length * 0.95)],
};
},
};
self.onmessage = async ({ data: { id, method, args } }) => {
try {
if (!methods[method]) throw new Error('Unknown method: ' + method);
self.postMessage({ id, result: await methods[method](...args) });
} catch (error) {
// Errors do not cross the boundary intact - send the parts explicitly.
self.postMessage({
id,
error: { name: error.name, message: error.message, stack: error.stack },
});
}
};// Call sites now read like ordinary async code.
const rpc = createWorkerRpc(new URL('./analysis-worker.js', import.meta.url));
const rows = await rpc.call('parseCsv', ['/data/readings.csv']);
const summary = await rpc.call('summarise', [rows]);
renderChart(summary);The error handling is the part worth copying. An exception in a worker does not propagate to the caller’s promise on its own, and a structured clone of an Error loses its prototype — so without explicit marshalling you get either a silent hang or an unhelpful plain object.
Module Workers and Bundling
Two details make workers far less awkward than their reputation suggests.
Module workers support import, so a worker can share code with the main bundle instead of duplicating it:
// Module type is required for import to work inside the worker.
const worker = new Worker(new URL('./analysis-worker.js', import.meta.url), {
type: 'module',
name: 'analysis', // shows in DevTools; invaluable with several workers
});The new URL(..., import.meta.url) form is what every modern bundler recognises. Passing a bare string path means the bundler cannot see the dependency, so the worker file is not emitted and the request 404s in production while working perfectly in development.
// BREAKS in production - the bundler never sees this file.
new Worker('./analysis-worker.js', { type: 'module' });
// CORRECT - statically analysable, so the worker gets bundled and hashed.
new Worker(new URL('./analysis-worker.js', import.meta.url), { type: 'module' });A Worker Pool for Parallel Work
One worker uses one extra core. Work that splits into independent pieces — resizing a batch of images, hashing files, running the same simulation with different seeds — can use several, and the sensible ceiling is the machine’s own report of available parallelism.
class WorkerPool {
#idle = [];
#queue = [];
#all = [];
constructor(scriptUrl, size) {
// Leave a core for the main thread. hardwareConcurrency is a hint, not a
// guarantee, and it can be spoofed or reduced for privacy - so clamp it.
const count = size ?? Math.max(1, Math.min(8, (navigator.hardwareConcurrency || 4) - 1));
for (let i = 0; i < count; i++) {
const worker = new Worker(scriptUrl, { type: 'module', name: 'pool-' + i });
this.#all.push(worker);
this.#idle.push(worker);
}
}
run(payload, transfer = []) {
return new Promise((resolve, reject) => {
this.#queue.push({ payload, transfer, resolve, reject });
this.#pump();
});
}
#pump() {
while (this.#idle.length && this.#queue.length) {
const worker = this.#idle.pop();
const job = this.#queue.shift();
const onDone = ({ data }) => {
cleanup();
// Return the worker to the pool BEFORE settling, so a .then() that
// queues more work finds an available worker immediately.
this.#idle.push(worker);
this.#pump();
data.error ? job.reject(new Error(data.error)) : job.resolve(data.result);
};
const onFail = (event) => {
cleanup();
this.#idle.push(worker);
this.#pump();
job.reject(new Error('Worker error: ' + event.message));
};
function cleanup() {
worker.removeEventListener('message', onDone);
worker.removeEventListener('error', onFail);
}
worker.addEventListener('message', onDone);
worker.addEventListener('error', onFail);
worker.postMessage(job.payload, job.transfer);
}
}
terminate() {
for (const worker of this.#all) worker.terminate();
this.#all = [];
this.#idle = [];
// Reject anything still queued rather than leaving promises pending.
for (const job of this.#queue) job.reject(new Error('Pool terminated'));
this.#queue = [];
}
}
// Resize forty images across the pool. Each worker takes the next job as
// it frees up, so a slow image does not stall the others.
const pool = new WorkerPool(new URL('./resize-worker.js', import.meta.url));
const resized = await Promise.all(
files.map((file) => pool.run({ file, maxWidth: 1200 }))
);
pool.terminate();Two details matter for correctness. Listeners must be removed after each job, or a worker handling its tenth task fires nine stale handlers and resolves the wrong promises. And the worker is returned to the idle list before the promise settles, so continuation code that queues more work does not wait for the next pump.
Do not size the pool to hardwareConcurrency exactly. That figure reports logical cores including hyperthreads, the main thread needs one, and browsers may report a reduced value as a fingerprinting defence. Clamping to a sensible maximum avoids spawning sixteen workers on a machine that cannot usefully run them.
What Workers Cannot Do
A worker has no DOM. No document, no window, no direct access to any element. That is the constraint that decides your architecture: workers compute, the main thread renders.
| Available in a worker | Not available |
|---|---|
fetch, WebSocket, IndexedDB | document, window, any DOM node |
OffscreenCanvas, ImageBitmap | localStorage, sessionStorage |
crypto.subtle, TextEncoder | alert, confirm |
| WebAssembly, OPFS sync handles | document.cookie |
OffscreenCanvas is the notable exception to “no rendering”: a canvas can be transferred to a worker, which then draws into it while the main thread stays free. That makes it viable to run a whole visualisation off-thread.
// Main thread: hand the canvas over once, permanently.
const canvas = document.querySelector('#chart');
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: 'init', canvas: offscreen }, [offscreen]);
// The main thread can no longer get a 2D context from this canvas - the
// worker owns it now. That is a one-way transfer.The absence of localStorage catches people. Workers can use IndexedDB or the Origin Private File System instead, and OPFS additionally offers synchronous file access available nowhere else — covered in our guide to the File System Access API.
When Not to Use One
Workers add a build artifact, a message protocol, and a debugging surface. Three cases where the cost is not repaid:
The work is I/O-bound. A slow API call does not block the main thread — await already yields. Moving it to a worker adds complexity and saves nothing; the relevant mechanism is the scheduling model covered in our guide to the JavaScript event loop.
The work is short. Under roughly 50 milliseconds, spawning and messaging costs more than the work. Worker startup is not free — a few milliseconds plus script parsing.
The work needs the DOM. Reading layout, measuring text, walking the tree — none of it is possible in a worker, and shuttling measurements back and forth costs more than doing it inline.
Debugging deserves a note too, since it is the most common complaint. Workers appear as separate contexts in the DevTools sources panel, and breakpoints work normally once you select the right one — naming each worker via the name option is what makes that selection quick rather than guesswork. Uncaught errors surface in the console prefixed with the worker script, and console.log from a worker appears in the same console as the main thread.
Where the work is long but genuinely must touch the DOM, chunking on the main thread is the right answer instead. Reuse one worker rather than creating them per task, and terminate it when the feature unmounts — an orphaned worker keeps its thread and heap alive for the lifetime of the page. Full API documentation is in MDN’s guide to using web workers.
editor's pick
latest video
news via inbox
Nulla turp dis cursus. Integer liberos euismod pretium faucibua

