The browser's main thread does too many things: it runs your JavaScript, computes layout, paints, and responds to clicks. All in the same queue. When you drop a heavy computation in there —parsing a CSV with thousands of rows, filtering a large dataset, processing an image— you block that thread and the interface freezes: scrolling stutters, buttons don't respond, the spinner doesn't even spin because the thread that would animate it is busy. Web Workers exist for that: to move that computation to another thread and give the main one back its single important responsibility, which is keeping the UI alive. I've used workers in several PWAs and the improvement in perception is real, but I've also seen them dropped in where they didn't belong. This article is about when they're worth it.
The symptom that tells you you need a worker
Not every computation goes to a worker. The concrete signal I look for is a long task: a synchronous function that occupies the main thread for more than a few dozen milliseconds and during which the page stops responding. If you open the performance panel and see long yellow blocks right when the UI stutters, there's your culprit.
Typical cases that have led me to a worker: parsing and transforming large files the user uploads, sorting or filtering a dataset of tens of thousands of rows client-side, geometry or image calculations, and running light models in the browser. What they have in common is that they're pure, prolonged CPU work, not network waits. For waiting on a server you don't need a worker: async/await already frees the thread. The worker is for work that burns CPU, not work that waits.
The cost people forget: the message boundary
A worker doesn't share memory with the main thread. They communicate by passing messages, and that data is copied when crossing the boundary (structured clone). That detail is what decides whether the worker pays off: if you send a huge object to the worker, wait for a trivial computation, and get another huge object back, the cost of serializing and copying eats the gain.
The rule I follow is to push a lot of work per boundary crossing. A worker pays off when you send it data once and it does a big computation, not when you call it in a tight loop for small operations. When the data is genuinely large, I use transferable objects: an ArrayBuffer is transferred instead of copied, changing owner without duplicating memory.
// Main thread: transfer the buffer, don't copy it
const worker = new Worker(new URL('./process.ts', import.meta.url), { type: 'module' });
worker.postMessage({ buffer }, [buffer]); // the second arg transfers ownership
worker.onmessage = (e) => renderResult(e.data);
The pattern I use to avoid suffering with postMessage
The raw postMessage / onmessage API turns into event spaghetti the moment you have more than two message types. What I do is wrap the worker in a promise, so that from the rest of the code calling the worker feels like a normal await and not like wiring an event bus.
function runInWorker<T>(worker: Worker, payload: unknown): Promise<T> {
return new Promise((resolve, reject) => {
worker.onmessage = (e) => resolve(e.data as T);
worker.onerror = (e) => reject(e);
worker.postMessage(payload);
});
}
For serious cases I use Comlink, which does exactly this but properly: it lets you call worker functions as if they were local, with await, and hides all the messaging. The first time you wrap a worker like this, the mental barrier of "this is complicated" disappears: the worker becomes just another async function.
💡 A worker doesn't make your code faster; it makes your UI smoother. The computation takes the same time or slightly more because of the data copy. What you gain is that the user can keep scrolling and clicking meanwhile. It's an improvement in perception, not throughput.
Where I do NOT reach for a worker
Being honest about the trade-off matters. I don't use a worker when the computation is short: if it takes 5 ms, moving it to another thread only adds messaging latency and a layer of complexity for nothing. Nor when the work is waiting on the network, because that no longer blocks. And I avoid the worker if the algorithm needs to touch the DOM: workers have no DOM access by design, and if your "computation" actually manipulates the interface, it's not a candidate.
The other cost is tooling and maintenance: a worker is another file, another entry point for the bundler, another piece someone has to understand when reading the code. With Vite or whatever modern bundler, the friction dropped a lot, but it's not zero. That's why my threshold is clear: a worker only when there's a measurable long task that visibly stalls the UI. If I can't point to that yellow block in the profiler, there's no worker. Optimizing a block nobody notices is adding complexity to show off architecture, and that isn't engineering, it's decoration.