What you'll learn
Quick Answer
A Web Worker runs a script on a separate thread, so CPU-heavy work there never blocks the main thread that handles clicks, scrolling, and rendering. The two threads share no variables - they communicate only by passing messages, and the data in each message is copied (structured clone), not shared. Workers have no access to the DOM,window, ordocument; they exist for computation, not UI.
Why one blocked thread freezes everything
JavaScript in a browser runs on a single thread - the main thread. That one thread runs your code, handles every click and keypress, runs layout, and paints pixels. If a function runs for 800 milliseconds without returning, none of the rest happens for 800 milliseconds: clicks queue up, animations stall, and the browser eventually shows a "page unresponsive" prompt. Node has the same shape with its event loop.
Here is that stall, measured. A heartbeat is supposed to tick every 20ms; we run a heavy prime-counting loop and record the longest gap the heartbeat actually sees:
function countPrimesUpTo(limit) {
let count = 0;
for (let n = 2; n <= limit; n++) {
let isPrime = true;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) { isPrime = false; break; }
}
if (isPrime) count++;
}
return count;
}
let last = Date.now();
let worstGap = 0;
const beat = setInterval(() => {
const gap = Date.now() - last;
last = Date.now();
if (gap > worstGap) worstGap = gap;
}, 20);
setTimeout(() => {
countPrimesUpTo(1_500_000); // pure CPU, never yields
setTimeout(() => { // let the loop tick once more
clearInterval(beat);
console.log(`longest heartbeat gap: ${worstGap} ms (target: 20)`);
}, 50);
}, 100);longest heartbeat gap: 1306 ms (target: 20)For over a second, nothing else on that thread could run. Note that a Promise or a setTimeout would not help here - they change when code runs, not which thread runs it. The only way to get real parallelism in a browser is another thread, and that is what a Worker is.
Creating a worker and handing it the work
In the browser you put the heavy code in its own file and load it as a worker. The main script and the worker talk through postMessage and an onmessage handler on each side:
// main.js
const worker = new Worker('prime-worker.js');
worker.postMessage({ limit: 1_500_000 });
worker.onmessage = (event) => {
console.log('primes:', event.data);
};
// prime-worker.js -- runs on its own thread
self.onmessage = (event) => {
const count = countPrimesUpTo(event.data.limit);
self.postMessage(count);
};Node exposes the same model through worker_threads, which we can actually run here. parentPort plays the role of self, and workerData is the initial message:
import { Worker } from 'node:worker_threads';
const workerSource = `
const { parentPort, workerData } = require('node:worker_threads');
function countPrimesUpTo(limit) {
let count = 0;
for (let n = 2; n <= limit; n++) {
let isPrime = true;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) { isPrime = false; break; }
}
if (isPrime) count++;
}
return count;
}
parentPort.postMessage(countPrimesUpTo(workerData.limit));
`;
let last = Date.now(), worstGap = 0;
const beat = setInterval(() => {
const gap = Date.now() - last; last = Date.now();
if (gap > worstGap) worstGap = gap;
}, 20);
new Worker(workerSource, { eval: true, workerData: { limit: 1_500_000 } })
.on('message', () => {
clearInterval(beat);
console.log(`longest heartbeat gap: ${worstGap} ms (target: 20)`);
});longest heartbeat gap: 48 ms (target: 20)Same computation, same result, but the main thread's worst stall dropped from over 1300ms to about 20-50ms - it stayed responsive the whole time. One caveat: starting a worker is not free (it spins up a new thread and parses the script), so you normally create a worker once and reuse it, not one per task.
Messages are copied, not shared
postMessage does not hand the worker a reference to your object. It serializes a deep copy using the structured clone algorithm, and the worker gets its own separate copy. Mutating the object on one side has no effect on the other, and a large object costs real time and memory to copy because both threads briefly hold the whole thing.
Structured clone handles more than JSON: Date, Map, Set, RegExp, typed arrays, ArrayBuffer, even objects with cycles. What it cannot copy: functions, DOM nodes, and class instances (you get a plain object back with the methods stripped off).
import { Worker } from 'node:worker_threads';
const worker = new Worker(
`const { parentPort } = require('node:worker_threads');
parentPort.on('message', (m) => parentPort.postMessage(typeof m));`,
{ eval: true }
);
worker.on('message', (t) => console.log('worker received a value of type:', t));
// A function cannot be structured-cloned
try {
worker.postMessage({ run: () => 42 });
} catch (err) {
console.log(`${err.name}: ${err.message}`);
}
// A plain object goes through fine
worker.postMessage({ userId: 7, tags: ['a', 'b'] });DataCloneError: () => 42 could not be cloned.
worker received a value of type: objectThis is the first wall most people hit: they try to send a callback, an event object, or a class instance with methods, and get a DataCloneError. Send plain data, and let each side keep its own functions.
Transferables: moving data without copying it
Copying is wasteful for large binary data - a 50MB image buffer would be duplicated on every message. For that case you can transfer an ArrayBuffer instead: ownership moves to the other thread and the original becomes unusable (detached, byteLength 0). Only a pointer moves, so it is instant regardless of size.
const worker = new Worker(
`const { parentPort } = require('node:worker_threads');
parentPort.on('message', ({ buf }) => {
const view = new Uint8Array(buf);
parentPort.postMessage('worker sees ' + view.length + ' bytes, first = ' + view[0]);
});`,
{ eval: true }
);
worker.on('message', (m) => { console.log(m); worker.terminate(); });
const buffer = new ArrayBuffer(1024);
new Uint8Array(buffer)[0] = 99;
console.log('before transfer, main thread byteLength:', buffer.byteLength);
worker.postMessage({ buf: buffer }, [buffer]); // second arg = the transfer list
console.log('after transfer, main thread byteLength:', buffer.byteLength, '(buffer is now detached)');before transfer, main thread byteLength: 1024
after transfer, main thread byteLength: 0 (buffer is now detached)
worker sees 1024 bytes, first = 99The gotcha: after transferring a buffer you cannot touch it any more. Reading a detached buffer gives you nothing. If you genuinely need both threads reading the same memory at once, that is a SharedArrayBuffer - a different tool that needs specific cross-origin isolation HTTP headers to be enabled at all.
What workers cannot do, and when they are worth it
A worker cannot touch the DOM, and has no document, window, or localStorage. It can use fetch, WebSocket, IndexedDB, timers, and WebAssembly. So the split is clear: computation goes in the worker, and it posts results back to the main thread, which does the DOM updates.
Worth offloading: image and video processing, parsing large JSON or CSV, cryptography, compression, pathfinding, physics, syntax highlighting a big file - anything that would otherwise block the UI for tens of milliseconds or more.
The most common mistake is offloading the wrong thing. Moving an await fetch(...) call into a worker gains you nothing, because network waiting never blocked the main thread in the first place - async already handled it on one thread. Workers only help when you are CPU-bound. The second mistake is spawning a worker per small task: the startup and message-copy cost can exceed the work itself, so use a small reused pool. Libraries like Comlink wrap postMessage so calling into a worker feels like a normal async function call.
