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
| Kind | Lives as long as | Shared by | Used for |
|---|---|---|---|
Dedicated worker (new Worker) | the page that created it | that one page | heavy computation for one tab |
Shared worker (new SharedWorker) | at least one connected tab is open | every tab of the origin | one connection or one database shared by many tabs |
| Service worker | the browser decides | the whole origin, even with no tab open | offline 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,queueMicrotaskcrypto,TextEncoder/TextDecoder,IntlOffscreenCanvas(draw graphics without the DOM)- BroadcastChannel,
navigator.locks - WebAssembly
Can’t use:
document,window, or any DOM elementlocalStorageandsessionStorage(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 itTransferable: 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
erroron theWorkerobject. The worker usually keeps running. messageerrorfires 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.
fetchis 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.loginside 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
requestIdand ageneration, 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 usingimportin 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
postMessagecost time on the sender too. Measure. - No
localStoragein 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-srcorscript-srcrule can block workers. Have a fallback.
Practice
- Move a slow function (say, sorting 1 million numbers) into a worker. Compare how the page scrolls while it runs, before and after.
- Write the request-id wrapper yourself. Then replace it with Comlink and compare.
- Send a 100 MB
ArrayBufferwith and without transferring it. Time both. - 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.
- 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?