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, or document; 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: object

This 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 = 99

The 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.

Frequently Asked Questions

Can a Web Worker access the DOM? No. A worker has no document, window, or DOM access at all. It computes results and posts them back to the main thread, which is the only thread allowed to update the page.
Is data shared between the main thread and a worker? Not by default. postMessage copies the data with structured clone, so each side has its own separate copy. Genuinely shared memory requires a SharedArrayBuffer, which needs cross-origin isolation headers.
Why do I get a DataCloneError from postMessage? You are trying to send something structured clone cannot copy - usually a function, a class instance with methods, or a DOM node. Send plain data instead.
Does putting work in a worker always make it faster? Only for CPU-bound work that would otherwise block the main thread. For small tasks or anything I/O-bound, the worker startup and message-copying cost can make it slower overall.
What's the difference between a Web Worker and async/await? async/await changes the order code runs in on the single main thread; it adds no parallelism. A worker runs code on a separate thread at the same time. Use async for I/O, a worker for heavy computation.