A Web Worker runs JavaScript outside the main UI thread. The page keeps handling clicks, typing, scrolling, and painting while the worker handles work that would otherwise block the interface.
Use a worker for CPU-heavy tasks such as image processing, large file parsing, compression, syntax analysis, search indexing, or data conversion. Keep DOM updates, layout, and direct UI work on the main thread. Workers cannot touch the DOM.
MDN’s Web Workers API is the primary reference when you need exact API details. This article focuses on the decisions that matter when you are building browser tools.
Main thread vs worker thread
The main thread owns the document. It parses HTML, applies CSS, runs most JavaScript, handles input, updates layout, and paints the page.
When a long JavaScript task runs on the main thread, the browser cannot respond to user input until that task yields. A 500ms image loop can make the page feel stuck. A 5s loop can trigger browser warnings.
A worker gives that task a separate execution context. The worker has its own global scope and event loop. It can run JavaScript, use timers, fetch data, process typed arrays, and send messages back to the page. It cannot read document, call querySelector, update a button, or measure layout.
That separation is the point. The worker does work. The main thread presents results.
The smallest worker example
A dedicated worker usually lives in its own file.
// worker.js
self.onmessage = (event) => {
const value = event.data;
self.postMessage(value * 2);
};
The page creates the worker and sends data to it:
// main.js
const worker = new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
});
worker.onmessage = (event) => {
console.log(event.data); // 42
};
worker.postMessage(21);
postMessage() is the bridge. The main thread sends data to the worker. The worker sends a result back. Both sides receive a message event.
The new URL(..., import.meta.url) pattern matters in bundled apps because it lets the build tool treat the worker file as a real module dependency. Creating the worker with type: "module" also enables import inside the worker, which keeps shared code in one place instead of duplicating it.
Inline workers from a Blob URL
A worker does not have to come from a separate file. You can build one in memory by wrapping its source in a Blob and creating an object URL.
const source = `
self.onmessage = (e) => self.postMessage(e.data * 2);
`;
const url = URL.createObjectURL(new Blob([source], { type: "application/javascript" }));
const worker = new Worker(url);
worker.postMessage(21);
worker.onmessage = (e) => console.log(e.data); // 42
URL.revokeObjectURL(url);
Inline workers are handy for demos, examples, and tiny runtime-generated jobs. For production tools, prefer a real file or module worker. Separate files cache, support module imports, and debug cleanly, while a Blob URL gives up all three.
Message shape matters
For real work, send command objects instead of loose values.
worker.postMessage({
type: "resize",
id: "job-17",
payload: {
width: 1200,
quality: 0.82,
},
});
The worker can switch on type and include the same id in progress and result messages:
self.postMessage({ type: "progress", id: "job-17", done: 12, total: 40 });
self.postMessage({ type: "complete", id: "job-17", blob });
That structure prevents a common bug: an old worker result arrives after the user has already changed the input. If every job has an id, the main thread can ignore stale results.
Structured clone copies most values
Messages are passed using the structured clone algorithm. It can copy many values that JSON cannot handle, including Map, Set, Date, Blob, File, ImageData, typed arrays, and circular object references.
It does not copy functions, DOM nodes, or class behavior in the way many developers expect. Send data, not UI objects.
For small messages, normal cloning is fine. For large binary data, copying can become the cost you were trying to avoid.
Transfer buffers instead of copying them
Large ArrayBuffer values can be transferred. Transfer moves ownership to the receiving side instead of copying the bytes.
const buffer = await file.arrayBuffer();
worker.postMessage({ type: "parse", buffer }, [buffer]);
After transfer, the original buffer on the main thread is detached. That surprises people the first time they see it: the main thread no longer owns those bytes.
Use transferables when you process large files, encoded images, audio data, or binary exports. Do not transfer a buffer if the main thread still needs to read it.
MessageChannel and MessagePort
postMessage() gives you one pipe per worker. When you need more than one stream, or you want two workers to talk to each other directly, use a MessageChannel.
A channel creates two linked ports. Whatever you send on one port arrives on the other.
const channel = new MessageChannel();
worker.postMessage({ type: "connect", port: channel.port1 }, [channel.port1]);
channel.port2.onmessage = (e) => console.log("from worker:", e.data);
Inside the worker:
self.onmessage = (event) => {
const port = event.data.port;
port.onmessage = (e) => port.postMessage(`got ${e.data}`);
};
Ports are transferable, so channel.port1 is detached on the main thread after transfer. Ports let you separate UI messages from background-job messages, route work between workers, and build worker pools. Call port.close() when a stream is done, or the channel leaks.
What workers can use
Workers do not have window or document, but they have many APIs that matter for processing:
fetch()for network requests- timers such as
setTimeout()andsetInterval() Blob,File, and typed arrayscrypto.subtlein secure contextsOffscreenCanvasin supporting browsers- module imports when the worker is created with
type: "module"
Check API availability before moving code. A function that reads the DOM, uses a browser-only UI library, or depends on localStorage cannot be copied into a worker unchanged.
Worker pools and how many to spawn
A single worker still runs one task at a time. To process a batch of files in parallel, reuse a fixed group of workers, a worker pool, instead of creating one worker per job.
Size the pool from the number of CPU cores the browser reports, with a cap so a large machine does not spawn dozens of threads:
const size = Math.min(navigator.hardwareConcurrency || 4, 8);
const pool = Array.from({ length: size }, () => new Worker("worker.js", { type: "module" }));
A small scheduler hands each job to the next idle worker and resolves a promise when the result comes back:
const queue = [];
const idle = [...pool];
function run(data) {
return new Promise((resolve) => {
queue.push({ data, resolve });
dispatch();
});
}
function dispatch() {
if (queue.length === 0 || idle.length === 0) return;
const worker = idle.pop();
const { data, resolve } = queue.shift();
worker.onmessage = (e) => {
idle.push(worker);
resolve(e.data);
dispatch();
};
worker.postMessage(data);
}
Now a batch runs across all workers at once:
const results = await Promise.all(files.map(run));
When every worker is busy, new jobs wait in the queue instead of piling up extra threads. That backpressure is the point.
Threads are not free. Each worker costs memory and startup time, and more workers than cores rarely runs faster because the cores are already saturated. Terminate the pool on navigation or when the tool goes inactive so idle workers do not linger.
What workers should not do
A worker is not a fix for every slow page.
Do not use a worker when the work is small. Creating a worker, sending messages, cloning data, and coordinating results all have overhead. A 3ms string cleanup should stay on the main thread.
Do not use a worker to hide inefficient code. If an algorithm does unnecessary work, moving it to another thread only moves the waste.
Do not let a worker become a second application with its own hidden state. Keep the protocol narrow: inputs in, progress out, result out, errors out.
Cancellation and progress
Workers keep running until the task finishes, the code stops cooperatively, or the main thread terminates them.
worker.terminate() stops the worker immediately:
worker.terminate();
That is appropriate when the whole worker can be discarded. It does not give the worker a chance to finish cleanup.
For reusable workers, use message-based cancellation:
// main.js
worker.postMessage({ type: "cancel", id: "job-17" });
// worker.js
const cancelled = new Set();
self.onmessage = (event) => {
if (event.data.type === "cancel") {
cancelled.add(event.data.id);
}
};
Long loops should check the cancellation state between chunks, and a fetch() running inside the worker can take an AbortController signal so the request stops too. An AbortController on the main thread is also a clean way to gather the triggers that should cancel a job, such as the input changing, a modal closing, or the page unloading, and turn each of them into one cancel message.
Send progress messages often enough for the UI to stay honest, but not so often that progress reporting becomes the bottleneck.
Dedicated, shared, and service workers
The word “worker” covers several browser features.
A Dedicated Worker is created by one page or script. It is the normal choice for file processing, image effects, parsers, converters, and other tool tasks.
A Shared Worker can be used by multiple browsing contexts from the same origin. It can coordinate separate tabs that need access to one background script, but it is less common in ordinary tool UIs.
A Service Worker is different. It sits between the page and the network, where it can intercept requests, cache responses, and support offline behavior. It is not the right primitive for CPU-heavy computation.
If the goal is “keep this calculation off the UI thread,” start with a Dedicated Worker.
Debugging worker code
Worker bugs are often protocol bugs. The code runs, but the wrong message arrives, the wrong job id is used, or a transferred buffer is read after ownership moved.
A few checks help:
- Log message
typeandidon both sides while building. - Send plain error objects because
Errordetails can vary across boundaries. - Add a timeout or cancel path for user-replaced input.
- Keep one schema for every message type.
- Test with a file large enough to expose copying and progress issues.
Browser DevTools can inspect worker scripts, console output, and network requests, but the exact interface varies by browser.
A decision rule
Use a Web Worker when the task blocks input, takes more than a small frame budget, or handles enough data that copying and parsing are visible to the user.
Keep the DOM and final rendering on the main thread. Move isolated computation to the worker. Send structured messages with job ids. Transfer large buffers when ownership can move safely, and reach for a pool when the work is a batch.
That pattern is enough for most browser-native tools: the page stays responsive, and the heavy work happens where it cannot freeze the interface.





