
Web Workers in JavaScript: how they work, and when (and how) to use them
What main thread blocking is, how to create a Web Worker to move heavy computation off the UI, and when you don't actually need one.
JavaScript, in the browser, runs on a single thread. The same thread that runs your code also paints the interface, handles clicks, and scrolls the page. As long as the thread is free, everything feels responsive. The moment you hand it a computation that takes 300 milliseconds — parsing a huge JSON, processing an image, sorting hundreds of thousands of rows — that thread stops responding to anything else for the whole duration of the computation. Not a slowdown: a complete freeze. A click during those 300ms is simply dropped until the thread is free again.
This is main thread blocking, and it’s the most common cause behind an interface that feels “stuck” with no obvious error in the console.
What a Web Worker actually does
A Web Worker runs JavaScript on a separate thread, fully isolated from the main one. It doesn’t block the UI because it simply doesn’t share its thread — it runs in parallel.
// worker.js — runs on a separate thread
self.onmessage = (e) => {
const result = heavyComputation(e.data);
self.postMessage(result);
};
function heavyComputation(numbers) {
return numbers.reduce((acc, n) => acc + factorial(n), 0);
}
function factorial(n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
// main.js — the main thread, the one that paints the UI
const worker = new Worker('worker.js');
worker.postMessage([50000, 60000, 70000]);
worker.onmessage = (e) => {
console.log('Result:', e.data);
};
The main thread stays free for the whole computation: the interface keeps responding to clicks, scrolling stays smooth, while the worker grinds through the result in the background.
⚠ Attenzione
A Worker has no access to the DOM — the most important limit to understand right away: inside a Worker there’s no document, no window, no page elements at all. A Worker can only receive data, process it, and return data — it can’t read or modify the interface directly. Every exchange goes through postMessage/onmessage, and that’s by design: if a Worker could touch the DOM, you’d lose the isolation guarantee that makes it safe to run in parallel.
The hidden cost: copying data
postMessage doesn’t share memory between the two threads — by default it copies the data you pass (using the structured clone algorithm). For small objects that’s irrelevant. For an array of a few million numbers, that copy alone can cost non-trivial time, eating into part of the benefit.
The fix for large data is Transferable: instead of copying an ArrayBuffer, you transfer it — ownership moves to the Worker and the main thread loses access to that memory, with no copy happening at all:
const buffer = new ArrayBuffer(1024 * 1024 * 50); // 50MB
worker.postMessage(buffer, [buffer]); // transferred, not copied — instant
After the transfer, buffer on the main thread becomes unusable (byteLength goes back to 0) — it’s a real transfer of ownership, not a sharing.
When you DON’T need a Worker
This is the part most guides skip, and it’s the one that matters most in practice: most cases of a slow UI aren’t caused by a single heavy computation, but by too much repeated DOM work — a querySelectorAll inside a loop, a React re-render triggered by an un-throttled scroll event, layout thrashing (reading and writing layout properties alternately in a loop). A Worker doesn’t help in any of these cases, because the problem was never “too much pure JavaScript computation” — it’s DOM work that, by definition, has to stay on the main thread, Worker or no Worker.
💡 Consiglio
Measure first, optimize second — not the other way around. Before introducing a Worker, open the DevTools profiler and look at where the time during the block is actually going. If the time is in pure computation (a loop, a parse, a data transform), a Worker is the right answer. If the time is in “Recalculate Style” or “Layout”, the problem is in how the code touches the DOM — often fixable with debounce or throttle on the handler that triggers the work, not with an extra thread.
A practical pattern: the Worker as a work queue
For recurring computations (not a single postMessage/onmessage), it’s convenient to wrap the Worker in a small Promise-based interface:
class WorkerPool {
#worker;
#pending = new Map();
#nextId = 0;
constructor(scriptUrl) {
this.#worker = new Worker(scriptUrl);
this.#worker.onmessage = ({ data }) => {
this.#pending.get(data.id)?.resolve(data.result);
this.#pending.delete(data.id);
};
}
run(payload) {
const id = this.#nextId++;
return new Promise((resolve) => {
this.#pending.set(id, { resolve });
this.#worker.postMessage({ id, payload });
});
}
}
const pool = new WorkerPool('worker.js');
const result = await pool.run([50000, 60000, 70000]);
This way the rest of the code treats the Worker as a normal async function, without manually managing the pairing between requests and responses.
In summary
A Web Worker is worth the implementation cost when the bottleneck is pure, sustained JavaScript computation — parsing large payloads, client-side image/audio processing, algorithms that take tens or hundreds of milliseconds. To understand whether that blocking time is actually hurting the user experience, it’s also worth reading the article on Core Web Vitals, where INP measures exactly this: how much time passes between a user interaction and the interface’s visible response.