What are web workers?
Web Workers let JavaScript run scripts in background threads, isolated from the main thread, so heavy computations don't freeze the UI.
Theory
TL;DR
- The main thread is like a chef cooking orders one by one; Web Workers are kitchen assistants in a back room, passing finished plates via notes
- No DOM access, no shared memory, pure message passing via
postMessage/onmessage - Data is deep-copied using the structured clone algorithm, not shared between threads
- Rule of thumb: if a computation blocks the UI for more than 50ms, it belongs in a Worker
- Skip Workers for DOM updates, tasks under 10ms, or data that can't be cloned (functions, class instances)
Quick example
// main.js
const worker = new Worker('worker.js');
worker.postMessage(21); // send input
worker.onmessage = (e) => console.log(e.data); // logs: 42
// worker.js
self.onmessage = (e) => {
const result = e.data * 2; // heavy math here won't freeze UI
self.postMessage(result); // reply to main thread
};The Worker runs worker.js in a separate thread. postMessage sends data, onmessage receives it on the other side. The main thread stays free the entire time.
Isolation model
A Web Worker spawns a fully isolated JavaScript context with its own global scope (self), no access to window, document, or the DOM. When you call postMessage, the browser uses the structured clone algorithm to deep-copy the data before handing it across the thread boundary. Both threads always get their own copy. That eliminates the shared-state bugs that make multithreaded code in other languages so painful.
This is the one thing to internalize: there is no shared memory between main and Worker by default. You pass data, not references.
When to use
Use a Web Worker when:
- A loop or computation takes more than 50ms and you see the page stutter
- You're processing canvas pixel data for large images (4K+)
- You're parsing JSON or CSV files larger than 1MB
- You need cryptographic operations or ML inference in the browser
Skip it when:
- The task needs to read or write the DOM
- The operation finishes in under 10ms
- The data you'd send can't be cloned (functions, DOM nodes, objects with prototype methods)
How browsers create workers
Chrome (V8) and Firefox (SpiderMonkey) create a real OS thread per Worker via primitives like pthreads. The JS engine parses and compiles the worker script in that thread, fully isolated. Messages queue in a dedicated channel. The structured clone step blocks the sender momentarily during serialization, which is why sending 100MB ArrayBuffers synchronously is expensive. For large binary data, SharedArrayBuffer exists (requires COOP/COEP response headers), or you can transfer ownership with postMessage(buffer, [buffer]) to avoid copying entirely.
Common mistakes
Trying to touch the DOM:
// worker.js - throws immediately
document.getElementById('log').innerText = 'done'; // ReferenceError: document is not definedThere is no document in a Worker. Post a message back to main and let main update the DOM.
Sending non-cloneable data:
// main.js - throws
worker.postMessage({ fn: () => console.log('hi') }); // DataCloneErrorFunctions, DOM nodes, and class instances with prototype methods can't be cloned. Send primitives, plain objects, Arrays, Blobs, or ArrayBuffers.
Assuming variables are shared:
let count = 0; // main.js
worker.postMessage(count++); // worker sees 0, main increments to 1 - they're separate contextsIsolated contexts mean no shared globals. Always pass the full state you need inside the message.
Forgetting to terminate:
Workers consume RAM until terminate() is called. This is the leak I see most in code reviews: a React component creates a Worker inside useEffect but returns nothing from that effect, so the Worker lives forever. Call worker.terminate() in the cleanup. A pool of 4 Workers is a standard production pattern; spawning a new one per task adds 50-200ms overhead each time.
Large message payloads:
Cloning a 100MB ArrayBuffer blocks both threads for 500ms or more. Chunk the data, or use transferable objects: worker.postMessage(buffer, [buffer]) moves ownership without copying.
Real-world usage
- Figma runs layout calculations for prototype mode in Workers to keep the canvas at 60fps
- Fabric.js uses Workers for SVG parsing in canvas editors
- Three.js offloads WebGL shader compilation off-main to avoid frame drops
- Photopea processes 4K image filters in Workers using OffscreenCanvas
- Comlink (used at Vercel) wraps Workers as proxy objects so they feel like regular async function calls
Follow-up questions
Q: How do you pass data between the main thread and a Worker?
A: postMessage sends data and onmessage receives it on the other side. Data is deep-copied via the structured clone algorithm, so changes on one thread don't affect the other.
Q: What data types can you send through postMessage?
A: Primitives, plain objects, Arrays, Blobs, ArrayBuffers, and ImageData. Functions, DOM nodes, and objects with Symbol keys throw a DataCloneError.
Q: What is the difference between a Worker and a SharedWorker?
A: A SharedWorker lets multiple tabs or scripts connect to the same worker instance by name, sharing one background thread. A regular Worker is owned by a single script.
Q: How do you debug Web Workers?
A: In Chrome DevTools, open Sources and look for the Worker icon in the left sidebar. Each Worker gets its own scope with breakpoints and a separate console.
Q: (Senior) Why build a Worker pool instead of spawning one Worker per task?
A: Spawning a Worker costs 50-200ms each time. A pool of 4-8 Workers amortizes that cost by reusing threads. Tasks queue into an array and get dispatched to the next free Worker via round-robin. Idle Workers terminate after 30 seconds to free memory.
Examples
Basic: double a number in the background
// worker.js
self.onmessage = (e) => {
const result = e.data * 2;
self.postMessage(result);
};
// main.js
const worker = new Worker('worker.js');
worker.postMessage(21);
worker.onmessage = (e) => console.log(e.data); // 42
worker.terminate(); // free resourcesThe simplest possible Worker: receive a number, return a number. Nothing blocks the main thread while the calculation runs.
Intermediate: grayscale image filter with OffscreenCanvas
// ImageFilterWorker.js
self.onmessage = async (e) => {
const { imageData } = e.data;
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
ctx.putImageData(imageData, 0, 0);
const out = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < out.data.length; i += 4) {
// standard luminance formula
const gray = 0.3 * out.data[i] + 0.59 * out.data[i + 1] + 0.11 * out.data[i + 2];
out.data[i] = out.data[i + 1] = out.data[i + 2] = gray;
}
ctx.putImageData(out, 0, 0);
self.postMessage(await canvas.convertToBlob());
};
// React component
const worker = new Worker('ImageFilterWorker.js');
worker.onmessage = (e) => setFilteredImage(URL.createObjectURL(e.data));
worker.postMessage({ imageData: ctx.getImageData(0, 0, width, height) });Processing a 4K image pixel-by-pixel in the main thread would freeze the UI for several seconds. In a Worker, the component stays interactive the whole time. OffscreenCanvas gives the Worker a full canvas API without any DOM dependency.
Advanced: error handling and termination
// main.js
const worker = new Worker('error-worker.js');
worker.onerror = (e) => {
console.error('Worker crashed:', e.message); // "Intentional crash"
worker.terminate();
};
worker.postMessage('crash');
// error-worker.js
self.onmessage = (e) => {
if (e.data === 'crash') throw new Error('Intentional crash');
self.postMessage('ok');
};Unhandled errors in a Worker fire onerror on the main thread. The main thread itself does not crash. But if you skip onerror, the error disappears silently. Always attach it. And note that terminate() kills the Worker instantly with no cleanup callback, so flush any state you need before calling it.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.