Understanding Web Workers for Heavy Computation
JavaScript runs on a single thread, which means a computation-heavy task — parsing a huge JSON blob, resizing an image, running a pathfinding algorithm — blocks everything else on the page while it runs: no scrolling, no button clicks, no animations. Web Workers fix this by giving you a real background thread, and they've been supported in every browser that matters for over a decade.
Why the main thread chokes
The browser's main thread does double duty: it runs your JavaScript and handles rendering, layout, and input events. If a for loop takes 800ms to finish, the page is frozen for that entire 800ms — no exceptions, no "just make it async." async/await and Promises don't help here either; they schedule when code runs relative to other JavaScript, but they don't move work off the thread. A synchronous loop is synchronous no matter how many awaits surround it.
// This blocks the main thread completely, Promise or not
async function findPrimes(limit) {
const primes = [];
for (let n = 2; n <= limit; n++) {
if (isPrime(n)) primes.push(n);
}
return primes;
}
Moving work to a worker
A Web Worker runs in its own global context with no access to the DOM, window, or your page's variables. Communication happens exclusively through message passing.
// worker.js
self.onmessage = (event) => {
const { limit } = event.data;
const primes = [];
for (let n = 2; n <= limit; n++) {
if (isPrime(n)) primes.push(n);
}
self.postMessage({ primes });
};
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false;
}
return true;
}
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ limit: 1_000_000 });
worker.onmessage = (event) => {
console.log(`Found ${event.data.primes.length} primes`);
};
The page stays fully responsive while worker.js grinds through a million numbers, because that loop is now running on a separate OS thread entirely.
Wrapping a worker in a Promise
Raw onmessage/postMessage gets unwieldy once you need to correlate requests with responses. A small wrapper makes workers feel like any other async API:
function runInWorker(worker, payload) {
return new Promise((resolve, reject) => {
const handleMessage = (event) => {
cleanup();
resolve(event.data);
};
const handleError = (error) => {
cleanup();
reject(error);
};
const cleanup = () => {
worker.removeEventListener('message', handleMessage);
worker.removeEventListener('error', handleError);
};
worker.addEventListener('message', handleMessage);
worker.addEventListener('error', handleError);
worker.postMessage(payload);
});
}
const result = await runInWorker(worker, { limit: 1_000_000 });
console.log(result.primes.length);
This version only handles one in-flight request at a time — fine for a lot of use cases, but if you need to fire multiple concurrent requests at the same worker, tag each message with an id and match responses by id instead of relying on a single pending Promise.
What actually gets copied vs transferred
By default, postMessage clones the data you send using the structured clone algorithm — objects, arrays, Maps, Dates, and typed arrays all survive the trip, but functions, DOM nodes, and class instances with methods do not. For large binary payloads (a big ArrayBuffer from image or audio processing), cloning is wasteful — you're copying megabytes across thread boundaries.
Transferable objects avoid the copy entirely by handing ownership to the other side:
const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
worker.postMessage({ buffer }, [buffer]);
// `buffer` is now unusable in this thread — ownership moved to the worker
After a transfer, buffer.byteLength is 0 on the sending side. This is a real ownership handoff, not a reference share — only one side can use it at a time, which is exactly why it's fast: no copy is needed.
When a worker is the wrong tool
Workers have real overhead: spinning one up costs a few milliseconds, and every message across the boundary goes through structured cloning (unless transferred). For work that finishes in a couple of milliseconds, that overhead can exceed the savings. Workers earn their keep for:
- Parsing or transforming large datasets (CSV, JSON, binary formats)
- Image/video/audio processing (resizing, filters, encoding)
- Complex, CPU-bound algorithms (pathfinding, physics simulation, cryptography)
- Anything that would otherwise cause visible jank on interaction
They're the wrong tool for anything DOM-related (they can't touch it), anything that needs low latency on tiny inputs, or work that's already I/O-bound (a fetch call doesn't block the main thread in the first place — it's already async).
Takeaway
The main thread is a shared resource between your code and the browser's ability to render and respond to input — a long synchronous task starves both. Web Workers give computation-heavy work an actual separate thread instead of just a different point in the event loop queue, which is the one thing async/await alone can never do.