Web Workers Practical Guide: Offload Heavy Tasks
On this page
JavaScript in the browser runs on a single thread. That thread paints the UI, handles clicks, runs your animations, and executes every line of your application code. When you ask it to do something heavy — parse a 10MB JSON file, resize an image, run a cryptographic hash, or crunch a dataset — everything else stops. Buttons stop responding, scrolling stutters, and the dreaded "page unresponsive" dialog appears.
Web Workers solve this by giving you a genuine second thread. You hand off the heavy work, the main thread stays free, and your UI keeps running at 60fps. This guide walks through how they actually work, when to reach for them, and the patterns that keep them maintainable.
What a Web Worker Actually Is
A Web Worker is a script that runs in a background thread, isolated from the main thread. It has no access to the DOM, no window, and no direct shared variables. The only way it communicates is by passing messages back and forth.
That isolation is the key mental model. A worker is not "your code running elsewhere with shared access" — it is a separate world. You send it data, it does work, it sends results back. Think of it like a contractor you email tasks to, rather than a coworker sitting at your desk.
There are three main flavors:
- Dedicated Workers — tied to a single page/script. The most common type.
- Shared Workers — can be accessed by multiple tabs or iframes from the same origin.
- Service Workers — a specialized type for network interception, caching, and offline support (a different topic, despite the shared name).
This guide focuses on dedicated workers, which cover the vast majority of "offload heavy work" use cases.
A Minimal Example
Here is the smallest useful worker. First, the worker file (worker.js):
// worker.js
self.onmessage = (event) => {
const numbers = event.data;
const total = numbers.reduce((sum, n) => sum + n, 0);
self.postMessage(total);
};
Then, the main thread:
// main.js
const worker = new Worker("worker.js");
worker.postMessage([1, 2, 3, 4, 5]);
worker.onmessage = (event) => {
console.log("Sum from worker:", event.data); // 15
};
worker.onerror = (err) => {
console.error("Worker failed:", err.message);
};
The flow is always the same: create the worker, post a message in, listen for a message out. Notice you should always wire up onerror — a silent worker failure is one of the more painful bugs to track down.
Loading Workers in a Module World
The classic new Worker("worker.js") works, but modern bundlers (Vite, webpack, Rollup) prefer the standardized module-worker syntax:
const worker = new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
});
This form lets you use import statements inside the worker and lets your bundler resolve and fingerprint the file correctly. If you are on a current toolchain, prefer it. Inside a module worker you can do:
// worker.js
import { parseData } from "./parser.js";
self.onmessage = (e) => {
self.postMessage(parseData(e.data));
};
When You Should Reach for a Worker
Workers add complexity, so use them deliberately. Good candidates share one trait: CPU-bound work that takes long enough to cause a visible jank. A useful rule of thumb is that any synchronous task over ~50ms risks a dropped frame.
Strong use cases:
- Data processing — parsing, filtering, or aggregating large arrays and JSON.
- Image and video manipulation — resizing, filtering, format conversion via
OffscreenCanvas. - Cryptography and hashing — computing hashes over large files.
- Compression — gzip/brotli encoding in the browser.
- Parsing and tokenizing — markdown, syntax highlighting, CSV ingestion.
- Physics, simulations, and pathfinding — anything with tight numeric loops.
Poor use cases:
- I/O-bound work like
fetch. The network already runs asynchronously; a worker adds overhead with no benefit. - Tiny tasks where the cost of serializing data to the worker exceeds the work itself.
- DOM manipulation — workers cannot touch the DOM, full stop.
The Cost of Passing Messages
Data sent via postMessage is copied using the structured clone algorithm. For small payloads this is invisible. For a 50MB array, that copy is itself expensive and can defeat the purpose.
The escape hatch is transferable objects. Instead of copying, you transfer ownership of the underlying memory — a near-instant pointer handoff. After transfer, the sender can no longer use the buffer.
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
// Second argument lists transferables
worker.postMessage(buffer, [buffer]);
console.log(buffer.byteLength); // 0 — ownership moved to the worker
Transferables include ArrayBuffer, MessagePort, ImageBitmap, and OffscreenCanvas. When you are moving large binary data, always transfer rather than copy.
For genuinely shared memory between threads, SharedArrayBuffer exists, but it requires cross-origin isolation headers (COOP/COEP) and careful use of Atomics. Reach for it only when you truly need concurrent read/write on the same memory.
A Practical Request/Response Pattern
Raw onmessage gets messy once a worker handles multiple kinds of tasks. A small wrapper that returns promises keeps calling code clean:
// workerClient.js
export function createWorkerClient(url) {
const worker = new Worker(url, { type: "module" });
const pending = new Map();
let nextId = 0;
worker.onmessage = ({ data }) => {
const { id, result, error } = data;
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
error ? entry.reject(new Error(error)) : entry.resolve(result);
};
return function call(type, payload, transfer = []) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.postMessage({ id, type, payload }, transfer);
});
};
}
The worker side echoes the id back:
// worker.js
const handlers = {
sum: (nums) => nums.reduce((a, b) => a + b, 0),
// add more task types here
};
self.onmessage = async ({ data }) => {
const { id, type, payload } = data;
try {
const result = await handlers[type](payload);
self.postMessage({ id, result });
} catch (err) {
self.postMessage({ id, error: err.message });
}
};
Now your application code reads naturally:
const call = createWorkerClient(new URL("./worker.js", import.meta.url));
const total = await call("sum", [1, 2, 3]);
This correlation-by-id approach lets you fire many concurrent requests at one worker without responses getting crossed.
Worker Pools for Parallelism
One worker gives you one extra thread. To use multiple cores, create a pool. navigator.hardwareConcurrency tells you how many logical cores are available, so a sensible pool size is something like Math.max(1, hardwareConcurrency - 1) — leaving one core for the main thread.
A pool keeps a queue of tasks and dispatches each to the next idle worker. This is ideal for embarrassingly parallel work like processing thousands of independent images. Don't over-spawn: more workers than cores just adds scheduling overhead and memory pressure.
Common Pitfalls
- Forgetting to terminate. Call
worker.terminate()when you are done, or workers leak. In frameworks, tear them down in your component's cleanup/unmount hook. - Assuming shared state. Globals set on the main thread do not exist in the worker. Pass everything explicitly.
- Sending non-cloneable data. Functions, DOM nodes, and class instances with methods won't survive
postMessage. Stick to plain data. - Chatty messaging. Thousands of tiny messages per second create overhead. Batch where you can.
- Ignoring errors. Always handle
onerrorandonmessageerror(the latter fires when a message can't be deserialized). - Debugging blind. Most DevTools expose workers as separate contexts — you can set breakpoints and read
console.logfrom inside them.
OffscreenCanvas: Rendering Off-Thread
A standout modern use case is OffscreenCanvas, which lets a worker draw directly to a canvas without touching the DOM. You transfer control of the canvas once, then all rendering happens off the main thread:
// main.js
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
This is powerful for data visualizations, games, and image editors where rendering itself is the bottleneck.
FAQ
Can a Web Worker access the DOM?
No. Workers have no access to document, window, or DOM elements. If a worker computes something that must appear on screen, it posts the result back and the main thread updates the DOM. OffscreenCanvas is the one exception that allows off-thread drawing.
Are Web Workers the same as multithreading in other languages?
Conceptually similar — real parallel execution — but with strict isolation. There is no shared mutable state by default and no shared variables. Communication is message-based, which sidesteps most traditional concurrency bugs like race conditions (unless you opt into SharedArrayBuffer).
How many workers should I create?
Match your pool size to navigator.hardwareConcurrency, typically leaving one core free for the main thread. Creating dozens of workers does not make code faster; it just adds memory and scheduling overhead.
Do Web Workers work in all browsers?
Dedicated workers are supported everywhere, including all modern browsers and back through old versions. Module workers, OffscreenCanvas, and SharedArrayBuffer have somewhat narrower support and the latter needs special isolation headers, so check support for those specific features.
Is there a performance penalty for using a worker? There is a fixed cost to spawn a worker and to copy data across the boundary. For small or fast tasks this overhead outweighs the benefit. Use workers when the work is large enough that the copy cost is small relative to the computation — and transfer large buffers instead of copying them.
Can I use libraries like Comlink instead of writing this boilerplate? Yes. Comlink wraps the messaging layer so you can call worker methods as if they were local async functions. It is an excellent choice once your worker grows beyond a couple of message types, and it handles transferables cleanly.
Wrapping Up
Web Workers are one of the most underused tools for browser performance. The core idea is simple: keep the main thread free for the UI, and push CPU-heavy work to a background thread that communicates through messages. Start with a single worker and the promise-based request/response pattern, reach for transferables when moving large data, and graduate to a worker pool when you need real parallelism. Your users will feel the difference as smooth scrolling and instantly responsive interfaces — even while serious computation happens just out of sight.
Sources
Related Articles
Database Migrations for Zero Downtime Deployments
Model Context Protocol (MCP): Give AI Access to Systems
AI Code Review: The Complete Guide for Engineering Teams (2026)
A definitive, practical guide to AI code review in 2026 — how it works, where it helps and where it doesn't, how to roll it out, prompt and config patterns, security trade-offs, and the metrics that prove it's working.