Web Workers

JavaScript on a page runs on a single thread — the main thread. That one thread parses your script, runs your event handlers, computes layout, and paints frames. When a function on it takes 200ms, nothing else on the page happens for those 200ms: clicks queue up, animations stall, the cursor keeps spinning. The page is janky.

The browser aims to produce a frame every ~16.7ms to hit 60fps. Any single task that runs longer than that budget blocks a frame from being drawn. Sort a million rows, parse a 40MB JSON file, run image filters in a loop — do any of that on the main thread and the UI freezes until it’s done.

A Web Worker is the escape hatch. It runs a separate JavaScript file on a genuinely separate OS thread, in parallel with the main thread. It has its own event loop, its own global scope, its own memory. The heavy loop runs over there while the main thread stays free to keep the page at 60fps. The two threads don’t share variables — they talk by passing messages.

main threadworker threadDOM, events, layout, paintpure JS, no DOMnew Worker(url)self.onmessage = …heavyCompute()runs here, blocks nothingstays responsive at 60fpspostMessage →← postMessage
The main thread and a worker thread run in parallel. They share no variables; they exchange copies of data over a message channel.

This isn’t cooperative concurrency like the event loop gives you, and it isn’t async/await either — those still run everything on one thread, just interleaved. A worker is real parallelism: two threads executing at the same instant on different CPU cores.

Creating a worker

You point the Worker constructor at a script URL. That script runs in the new thread as soon as it loads.

// main.js
const worker = new Worker("worker.js");

By default this creates a classic worker — the script runs as a plain top-level script, and to pull in dependencies you use the synchronous importScripts():

// worker.js (classic)
importScripts("./math-utils.js", "https://cdn.example.com/lib.js");

The modern option is a module worker, which runs the file as an ES module. Now import and export work exactly as they do elsewhere, top-level await is allowed, and the script is loaded with the strict, deferred semantics of modules:

// main.js
const worker = new Worker(new URL("./worker.js", import.meta.url), {
  type: "module",
  name: "sorter", // shows up in DevTools; handy when you run several
});
// worker.js (module)
import { quicksort } from "./sort.js";

self.onmessage = (e) => {
  postMessage(quicksort(e.data));
};

Inside the worker there is no window. The global object is a DedicatedWorkerGlobalScope, and you refer to it as self. Calling postMessage(...) inside the worker is really self.postMessage(...) — it sends to the main thread, the one that created this worker.

The message channel

Communication is symmetric and asynchronous. One side calls postMessage(data); the other side receives a message event whose .data property holds a copy of what was sent.

// main.js
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });

worker.onmessage = (e) => {
  console.log("worker replied:", e.data);
};

worker.postMessage({ cmd: "sum", numbers: [1, 2, 3, 4] });
// worker.js
self.onmessage = (e) => {
  const { cmd, numbers } = e.data;
  if (cmd === "sum") {
    const total = numbers.reduce((a, b) => a + b, 0);
    postMessage({ result: total });
  }
};

Both onmessage and addEventListener("message", ...) work; use addEventListener when you want more than one listener. Note the ordering carefully — postMessage never blocks and never returns a value. It queues the message and returns immediately. The reply arrives later as its own event, on a future turn of the receiver’s event loop.

main threadworker threadpostMessage(req)onmessage firescompute…postMessage(res)onmessage fires
A message round-trip. Neither postMessage call waits; each message is delivered as an event on a later tick of the other thread's loop.

Because there’s no return value, most worker code invents a little protocol on top of postMessage: a cmd field to say what to do, or an id field to match a reply to the request that caused it. We’ll wrap that up neatly at the end.

One request/response round-trip
1/6
Variables
thread="main"
Main thread queues the message and returns instantly — postMessage does not wait.

Structured clone: how data crosses the boundary

The data you pass to postMessage is not shared — it’s copied using the structured clone algorithm. This is the same algorithm behind structuredClone(), IndexedDB storage, and history.pushState. It walks the object graph and rebuilds an independent copy on the other side.

It handles far more than JSON. It copies:

  • primitives (except Symbol), plain objects, and arrays
  • Date, RegExp (though lastIndex is not preserved)
  • Map, Set
  • ArrayBuffer, DataView, and every typed array
  • Blob, File, FileList, ImageData, ImageBitmap
  • cyclic references — an object that points back at itself clones fine, which is something JSON.stringify chokes on

What it cannot copy is just as important:

  • Functions and class instances-as-behavior → throws DataCloneError
  • DOM nodes → throws DataCloneError
  • getters, setters, and other property descriptors — only the plain data values survive
  • the prototype chain — a copied class User instance arrives as a plain object; instanceof User is false on the other side and its methods are gone
senderreceiver (a copy)✓ { id: 7 }✓ new Map()✓ Uint8Array✓ cyclic ref✗ () => {}✗ domNode✗ get/set, proto{ id: 7 }Map (rebuilt)Uint8Arraycyclic refDataCloneError →DataCloneError →plain object, no protoclone
Structured clone rebuilds an independent copy on the receiving thread. Data survives; functions, DOM nodes, and prototypes do not.

postMessage runs this exact algorithm, so you can watch it work right here on the main thread with structuredClone() — the same function, same rules. Clone a rich object and the copy is fully independent; hand it something with a function inside and it throws the same DataCloneError a worker would.

interactiveWhat survives structured clone

Transferable objects: zero-copy handoff

Some objects are backed by a raw block of memory: an ArrayBuffer, a MessagePort, a ReadableStream, an OffscreenCanvas, an ImageBitmap. For these you can transfer ownership instead of copying. The underlying memory is handed from one thread to the other — a pointer moves, no bytes are copied — so it’s fast and constant-time regardless of size.

You pass a second argument to postMessage: an array of the objects to transfer.

const buffer = new ArrayBuffer(64 * 1024 * 1024); // 64MB
// ...fill it...

worker.postMessage({ pixels: buffer }, [buffer]);
//                                       ^^^^^^^^ the transfer list

The catch, and it’s the whole point to internalize: a transferred object is detached (or “neutered”) on the sender. After the call, the sender’s reference still exists but points at nothing. Reading it throws or reports zero length.

const buf = new ArrayBuffer(8);
console.log(buf.byteLength);        // 8
worker.postMessage(buf, [buf]);
console.log(buf.byteLength);        // 0  ← detached, memory now belongs to the worker

You can watch that detachment happen without a worker at all: structuredClone accepts the very same transfer list, and moving a buffer through it neuters the original exactly as postMessage does. Run this and note how the sender’s byteLength collapses to 0 the instant ownership moves.

interactiveA transferred buffer is detached on the sender

Typed arrays are serializable but not transferable — you can’t transfer a Uint8Array directly, but you can transfer its .buffer:

const view = new Uint8Array(1024);
worker.postMessage(view, [view.buffer]); // send the view, transfer its buffer
// after this, view.byteLength === 0 on this side
postMessage(buf) — structured clonesender bufbyteLength 64MB ✓bytes copiedworker copybyteLength 64MB ✓postMessage(buf, [buf]) — transfersender bufbyteLength 0 ✗ detachedownership movedworker bufbyteLength 64MB ✓Transfer is O(1): the same physical memory now belongs to the worker. Nothing is copied.
Copy versus transfer. A clone leaves the sender's buffer intact; a transfer moves ownership and leaves the sender's buffer detached (length 0).

A worked example: moving work off the main thread

Here’s the failure mode, made concrete. This handler sorts a big array and blocks the whole page while it does:

// ❌ on the main thread — freezes the UI for the duration
button.addEventListener("click", () => {
  const data = makeBigArray(5_000_000);
  data.sort((a, b) => a - b);       // hundreds of ms, main thread stuck
  render(data);
});

While that sort runs, the spinner you started won’t spin, hover states won’t update, and the click you make on “Cancel” just sits in the queue. Move the sort into a worker and the main thread is free the entire time.

// main.js
const worker = new Worker(new URL("./sort-worker.js", import.meta.url), {
  type: "module",
});

button.addEventListener("click", () => {
  const data = makeBigArray(5_000_000); // a Float64Array
  spinner.start();
  // transfer the buffer so there's no 40MB copy
  worker.postMessage(data, [data.buffer]);
});

worker.onmessage = (e) => {
  spinner.stop();
  render(e.data); // the sorted array comes back
};
// sort-worker.js
self.onmessage = (e) => {
  const arr = e.data;          // a Float64Array, transferred in
  arr.sort((a, b) => a - b);   // runs on the worker thread
  postMessage(arr, [arr.buffer]); // transfer it back
};

The sort still takes just as long in wall-clock time — a worker doesn’t make the algorithm faster. What changes is where it runs. The main thread posts the work and immediately goes back to painting frames; the spinner animates smoothly; the result arrives as an event when it’s ready.

on the main thread — dropped framessort() blocks the threadeach ▢ = one 16.7ms frame · long red = no frames drawnin a worker — 60fps holdsmain thread:worker thread:sort() runs here in parallelpostMessageresult event
Same computation, two placements. On the main thread it blocks frames and the UI freezes; in a worker the main thread keeps hitting its 16.7ms frame budget.

What workers can and can’t touch

A worker has no DOM. There is no document, no window, no parent, no access to page elements — by design, because the DOM is not thread-safe and letting two threads mutate it would be chaos. If a worker computes something the page must show, it posts the result back and the main thread touches the DOM.

But a worker is far from crippled. It gets a genuine, useful runtime:

  • fetch() and WebSocket for network I/O
  • IndexedDB for storage
  • setTimeout / setInterval, queueMicrotask, structuredClone
  • Blob, FileReader, TextEncoder / TextDecoder, URL
  • crypto.subtle (Web Crypto), WebAssembly
  • importScripts() in classic workers; import in module workers
  • OffscreenCanvas, so you can even render graphics off-thread
available in a workerself · postMessage · onmessagefetch · WebSocket · XMLHttpRequestindexedDB · caches · crypto.subtlesetTimeout · queueMicrotaskWebAssembly · OffscreenCanvasimportScripts (classic) · import (module)not availablewindow · documentDOM nodes · querySelectorparent · localStoragealert · confirm · promptthe page’s variables(no shared globals — separate realm)
What lives in a dedicated worker's global scope, and what deliberately does not.

Errors, and shutting down

Uncaught errors inside the worker don’t crash your page; they surface on the main thread as an error event. Listen for it.

worker.onerror = (e) => {
  console.error(`Worker error: ${e.message} at ${e.filename}:${e.lineno}`);
  e.preventDefault(); // stop it from also logging to the console
};

// separately, module-loading failures for import() etc. surface here:
worker.onmessageerror = (e) => {
  // fired when an arriving message couldn't be deserialized
};

messageerror is the one people forget: it fires when a message arrives but can’t be deserialized on the receiving side — for instance if you accidentally send something the structured clone algorithm rejects in a context that surfaces it there.

When you’re done with a worker, stop it. A worker thread lives until you tell it to die (or the page unloads); leaving idle workers around wastes memory and a thread.

worker.terminate();  // from the main thread — kills it immediately, no cleanup
// from inside the worker, to shut itself down:
self.close();

terminate() is abrupt — it does not let pending work finish or run any teardown. If the worker holds resources that need flushing, have it do that and call self.close() itself, or post a “done, safe to terminate” message first.

Beyond one worker: pools

Spinning up a fresh worker per task gets expensive — each one has real startup cost (a new thread, a new realm, re-parsing the script). If you fire many short jobs, that overhead dominates.

The standard answer is a worker pool: create N workers once (often navigator.hardwareConcurrency of them, matching the CPU’s logical cores), keep them alive, and hand each incoming job to a free worker. Tasks queue when all workers are busy. It’s the same idea as a database connection pool, applied to threads.

Rather than hand-roll the request/reply plumbing every time, a tiny wrapper turns the event-based channel into a promise you can await:

function callWorker(worker, payload, transfer = []) {
  return new Promise((resolve, reject) => {
    const id = crypto.randomUUID();
    const onMessage = (e) => {
      if (e.data.id !== id) return;       // not our reply, ignore
      worker.removeEventListener("message", onMessage);
      e.data.error ? reject(new Error(e.data.error)) : resolve(e.data.result);
    };
    worker.addEventListener("message", onMessage);
    worker.postMessage({ id, payload }, transfer);
  });
}

// now heavy work reads like any async call:
const sorted = await callWorker(worker, bigArray, [bigArray.buffer]);

The id matches each reply to its request, so multiple in-flight calls don’t get their answers crossed. Libraries like Comlink formalize this further — they use Proxy to make a worker’s exported functions callable as if they were local async functions, hiding postMessage entirely. Worth reaching for once your protocol grows past a couple of message types.

Summary

  • The main thread runs your JS, your events, and your rendering. A task longer than ~16.7ms blocks a frame and makes the page janky. A Web Worker runs a separate script on a real parallel thread to keep that from happening.
  • Create one with new Worker(url) (classic) or new Worker(url, { type: "module" }) for ESM import. Use new URL("./w.js", import.meta.url) for the path so bundlers resolve it. Module workers are Baseline widely available as of late 2025.
  • The two threads share no memory. They communicate with postMessage(data) and a message event; postMessage never blocks and never returns a value.
  • Data is copied by the structured clone algorithm — it handles dates, maps, sets, typed arrays, blobs, and cycles, but throws on functions and DOM nodes and drops prototypes, getters, and setters.
  • Transferable objects (ArrayBuffer, MessagePort, streams, OffscreenCanvas, ImageBitmap) can be moved with a transfer list for zero-copy handoff — but they’re detached on the sender afterward.
  • Workers have fetch, WebSocket, IndexedDB, timers, Web Crypto, and WebAssembly — but no DOM, window, or localStorage. Post results back and let the main thread touch the page.
  • Handle failures with the error and messageerror events; stop a worker with terminate() (from outside) or self.close() (from inside).
  • For many small jobs, use a worker pool and wrap postMessage in a promise (or use Comlink) so calls read like ordinary await.