Area: Browser concurrency
Related: Browser APIs (map), BroadcastChannel, Web Locks API, IndexedDB, Blob and Object URLs

What is it?

A Web Worker is a second JavaScript thread that the page starts. It runs a separate script with its own global scope, and it has no access to the DOM. The only way to talk to it is by sending messages.

JavaScript in a page is single-threaded. Everything (your code, React rendering, layout, input handling) shares one main thread. A worker is how you get a second thread for the slow stuff.

The whole point: keep the main thread free so scrolling, typing and clicking stay smooth. A frame at 60 fps is about 16 ms. Any JavaScript task longer than ~50 ms is something the user can feel. If a job takes 200 ms of CPU, it belongs in a worker.

The three kinds

KindLives as long asShared byUsed for
Dedicated worker (new Worker)the page that created itthat one pageheavy computation for one tab
Shared worker (new SharedWorker)at least one connected tab is openevery tab of the originone connection or one database shared by many tabs
Service workerthe browser decidesthe whole origin, even with no tab openoffline caching, push, intercepting network requests

This note is about dedicated workers, which is what people usually mean. Service workers are a different topic: a network proxy, not a compute thread.

Shared workers went missing from Chrome for Android for years, and Safari removed them and only brought them back in Safari 16. Check support before you build on one. A common replacement is “one tab is the leader” using the Web Locks API.

The basic shape

// main thread
const worker = new Worker(new URL("./search.worker.ts", import.meta.url), {
  type: "module",
});
 
worker.postMessage({ type: "search", query: "hello" });
 
worker.onmessage = (event) => {
  console.log("results", event.data);
};
 
worker.onerror = (event) => {
  console.error("worker crashed", event.message);
};
// search.worker.ts
self.onmessage = (event) => {
  const { type, query } = event.data;
  if (type === "search") {
    const results = runExpensiveSearch(query);
    self.postMessage({ type: "results", results });
  }
};

new URL("./x.worker.ts", import.meta.url) is the pattern that bundlers (webpack 5, Vite, Turbopack) recognise. They see it, build the worker file as its own bundle and swap in the real URL. If you write a plain string path instead, the bundler doesn’t know the file is a worker and it won’t get built.

type: "module" lets the worker use import. Without it, the worker is a classic script and has to use importScripts().

What a worker can and can’t do

Can use:

  • fetch, WebSocket, EventSource
  • IndexedDB, the Cache API, OPFS (including the fast synchronous file handles, which only work in workers)
  • setTimeout, setInterval, queueMicrotask
  • crypto, TextEncoder/TextDecoder, Intl
  • OffscreenCanvas (draw graphics without the DOM)
  • BroadcastChannel, navigator.locks
  • WebAssembly

Can’t use:

  • document, window, or any DOM element
  • localStorage and sessionStorage (they’re synchronous, so they were left out on purpose)
  • alert, and anything else that touches UI

So in practice a worker does data in, data out. Parse this, search that, compress this, render this markdown to an HTML string. The main thread puts the result into the DOM.

How messages are copied

postMessage copies data with the structured clone algorithm, the same as IndexedDB. Objects, arrays, Maps, Sets, Dates, typed arrays and Blobs all work. Functions and DOM nodes throw. Class instances arrive as plain objects.

Copying is not free. Cloning a 50 MB object takes real time on the sending thread, and that thread is often the main thread you were trying to protect. Two ways around it:

1. Transfer instead of copy

Some objects can be transferred: ownership moves to the other side and nothing is copied.

const buffer = new ArrayBuffer(50_000_000);
worker.postMessage({ buffer }, [buffer]); // second argument = transfer list
console.log(buffer.byteLength); // 0, the main thread no longer owns it

Transferable: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream, and a few others. After a transfer the original is detached and unusable. That’s how the browser can promise no copy happened.

2. Send less

Don’t send the whole data set every time. Load the data into the worker once (or let the worker fetch it itself) and only send small queries and small results across.

SharedArrayBuffer

SharedArrayBuffer is real shared memory between threads, used with Atomics for locking. After the Spectre attacks, browsers only allow it when the page is cross-origin isolated, which means sending two headers: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Those headers break a lot of third-party embeds, so most apps never use it.

Turning messages into function calls

Raw postMessage is one-way. Real code needs “send a request, get its answer back”, even when several requests are in flight. The standard trick is a request id:

let nextId = 0;
const pending = new Map<number, (value: unknown) => void>();
 
worker.onmessage = (event) => {
  const { id, result } = event.data;
  pending.get(id)?.(result);
  pending.delete(id);
};
 
function call(type: string, payload: unknown) {
  const id = ++nextId;
  return new Promise((resolve) => {
    pending.set(id, resolve);
    worker.postMessage({ id, type, payload });
  });
}
 
const results = await call("search", "hello");

The library Comlink (from Google Chrome Labs) does this for you with a Proxy, so a worker’s exported functions feel like normal async functions. It’s small and widely used.

Dropping stale answers

For search-as-you-type, the user types h, he, hel. Three requests go out. If the answer for he arrives after the answer for hel, you’ll show the wrong results. Keep a “latest request id” and ignore any answer that isn’t for it. Same idea with a “generation” counter: bump it on every new input and drop results from older generations.

Errors and lifecycle

  • An uncaught error inside the worker fires error on the Worker object. The worker usually keeps running.
  • messageerror fires when a message couldn’t be deserialised.
  • worker.terminate() kills it immediately, with no cleanup. Inside the worker, self.close() does the same.
  • A dedicated worker dies when its page closes.
  • Startup costs something. Creating a worker means fetching and parsing its script, often 10 to 50 ms or more. Create it once and reuse it. Don’t spin up a worker per task.
  • Always have a fallback. Workers can fail to start: a CSP that blocks worker-src, an old webview, a bundling mistake. A good pattern is to try the worker, and if it fails to start or errors at startup, run the same code on the main thread. It’s slower, but the feature still works.

When a worker is worth it, and when it isn’t

Worth it:

  • Full-text search over thousands of documents
  • Parsing big files (markdown, CSV, PDF, Word)
  • Spellcheck or grammar engines, often compiled to WebAssembly
  • Image processing, compression, hashing, encryption
  • Diffing large texts
  • Any synchronous library you can’t make async, like a parser

Not worth it:

  • Small tasks. Cloning data both ways can cost more than the work itself.
  • Anything that has to touch the DOM, which is most UI work.
  • Code where the slow part is network, not CPU. fetch is already async and doesn’t block the main thread.

How to tell: open the DevTools Performance panel and record. Long yellow “Task” blocks with a red corner mark the main thread as blocked. If the slow part is your own function, it’s a candidate. If it’s “Recalculate Style” or “Layout”, a worker won’t help, because that work has to happen on the main thread.

Debugging

  • In Chrome DevTools, workers show up as separate threads in the Sources panel, where you can set breakpoints.
  • console.log inside a worker shows in the normal console, labelled with the worker’s name.
  • new Worker(url, { name: "search" }) gives the worker a readable name in DevTools.

A real-world example

A markdown notes app runs three dedicated module workers:

  • Command palette search. MiniSearch runs inside a worker, so typing in the palette never stutters while it searches. The worker’s index is kept up to date as notes change, including changes from other tabs over BroadcastChannel.
  • Markdown rendering. The remark/rehype pipeline that builds the read view runs in a worker. Replies carry a requestId and a generation, so a slow old render can’t replace a newer one. If the worker can’t start, rendering falls back to the main thread.
  • Spellcheck. The Harper grammar checker, compiled to WebAssembly, runs in its own worker.

All three use the new URL("./x.worker.ts", import.meta.url) pattern with type: "module".

Gotchas

  • Forgetting type: "module" and then using import in the worker gives a syntax error that’s hard to read.
  • A plain string path to the worker file isn’t bundled. Use new URL(..., import.meta.url).
  • After transferring an ArrayBuffer, the sender’s copy is empty. Don’t read it again.
  • Big objects in postMessage cost time on the sender too. Measure.
  • No localStorage in workers. Use IndexedDB.
  • Results can arrive out of order. Use request ids or generations.
  • Creating a new worker per call wastes the startup cost. Keep one around.
  • A CSP worker-src or script-src rule can block workers. Have a fallback.

Practice

  1. Move a slow function (say, sorting 1 million numbers) into a worker. Compare how the page scrolls while it runs, before and after.
  2. Write the request-id wrapper yourself. Then replace it with Comlink and compare.
  3. Send a 100 MB ArrayBuffer with and without transferring it. Time both.
  4. Build search-as-you-type over 10,000 strings in a worker. Add a fake random delay in the worker and make sure stale results never show.
  5. Make the worker fail on purpose (wrong path) and add a main-thread fallback.

Sources worth reading

  • MDN: Using Web Workers, Transferable objects, structuredClone
  • web.dev: Use web workers to run JavaScript off the browser’s main thread
  • Comlink README (GoogleChromeLabs/comlink)
  • Surma: When should you be using Web Workers?