Web Workers, SharedWorker, BroadcastChannel
TL;DR
A Web Worker runs JS in a separate thread — no DOM access, communicates with the page via postMessage. Use for CPU-intensive work (parsing, image manipulation, encryption, ML inference) that would otherwise block the main thread and tank INP. SharedWorker is one worker shared across multiple tabs of the same origin. BroadcastChannel is the simpler “tabs talking to each other” API when you don’t need shared compute. Plus: Transferable objects move data zero-copy across the boundary, critical for large payloads.
Web Worker Q&A
Q: Why use a worker?
A: The main thread runs JS, layout, paint, event handling, garbage collection. A long task (>50ms) blocks input handling → INP goes through the roof. Workers move CPU work off the main thread:
- Parse a huge JSON (50MB).
- Process an image / video frame.
- Run a ML inference (TensorFlow.js, ONNX Runtime Web).
- Encrypt / decrypt large data (Web Crypto on a worker thread).
- Tokenize a giant text for syntax highlighting.
Anything where the answer to “is this 50ms+?” is yes belongs in a worker.
Q: Basic worker setup.
A:
// worker.ts
self.onmessage = (e: MessageEvent) => {
const result = expensiveCompute(e.data);
self.postMessage(result);
};
// page.ts
const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
worker.postMessage({ items: bigArray });
worker.onmessage = (e) => {
console.log("got result", e.data);
};
worker.terminate(); // when done
Modern bundlers (Vite, Webpack 5) understand new Worker(new URL(...)) and bundle worker.ts as a separate chunk. The { type: "module" } option enables ESM inside the worker — can import other modules.
Q: What’s missing inside a worker?
A:
| Available | NOT available |
|---|---|
self (global), setTimeout, setInterval |
window, document, parent, top |
fetch, XMLHttpRequest |
localStorage, sessionStorage |
IndexedDB |
alert, confirm, prompt |
Cache API, WebSocket |
DOM APIs (any element manipulation) |
crypto, TextEncoder/TextDecoder |
requestAnimationFrame |
console (sometimes limited in dev) |
direct UI |
WebAssembly, structured clone |
No DOM = no UI updates. Compute, return result via postMessage, page updates the DOM.
Q: postMessage semantics.
A: Sends a structured-cloned message to the other side. Both sides see it as a MessageEvent with data.
- Structured clone — JSON-ish + Map/Set/Date/ArrayBuffer/etc. Functions can’t cross.
- Async — fires after the current task completes; not synchronous.
- Order preserved — messages received in send order.
// Page → Worker
worker.postMessage({ items: [...], options: {...} });
// Worker → Page
self.postMessage({ result: [...] });
For huge data, structured clone is slow (copies the data). Use Transferable to move ownership zero-copy (next Q).
Q: Transferable objects — what they buy.
A: Move ownership of an ArrayBuffer (or MessagePort, ImageBitmap, ReadableStream) to the other side without copying. The sender loses access.
const buffer = new ArrayBuffer(100_000_000); // 100MB
worker.postMessage({ data: buffer }, [buffer]);
// `buffer.byteLength` is now 0 on the sender — ownership transferred.
Use for: large binary data (images, audio, ML tensors). Without Transferable, you’d structured-clone the 100MB — slow, double memory.
Q: Request/response pattern.
A: Workers are message-based, not RPC. Implement RPC over postMessage with IDs:
let nextId = 0;
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
worker.addEventListener("message", (e) => {
const { id, result, error } = e.data;
const p = pending.get(id);
if (!p) return;
pending.delete(id);
error ? p.reject(error) : p.resolve(result);
});
function call<T>(fn: string, args: unknown[]): Promise<T> {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.postMessage({ id, fn, args });
});
}
// Usage
const result = await call("parseDocument", [largeText]);
Libraries like Comlink (Google) do this for you — typed RPC over postMessage:
// Worker
import * as Comlink from "comlink";
const api = {
parse(text: string) { /* ... */ return result; },
};
Comlink.expose(api);
// Page
const worker = new Worker(...);
const api = Comlink.wrap<typeof apiType>(worker);
const result = await api.parse("..."); // async, typed
For production use, Comlink (or a similar wrapper) is almost mandatory — raw postMessage scales poorly.
Q: When NOT to use a worker?
A:
- Fast work (< 16ms) — overhead of postMessage isn’t worth it.
- DOM manipulation — workers can’t touch DOM. Compute in worker, hand result back, page renders.
- Real-time / 60fps scenarios where the postMessage delay matters — workers add ~1-5ms round trip.
When to use: any task that historically caused dropped frames or “page froze for 200ms.”
SharedWorker Q&A
Q: SharedWorker vs Worker?
A: A SharedWorker is one instance shared across multiple tabs/iframes of the same origin. Each connection gets a MessagePort to communicate.
// page.ts (every tab)
const sw = new SharedWorker("/shared-worker.js");
sw.port.start();
sw.port.postMessage("hi");
sw.port.addEventListener("message", (e) => console.log(e.data));
// shared-worker.js
const connections: MessagePort[] = [];
self.addEventListener("connect", (e: any) => {
const port = e.ports[0];
connections.push(port);
port.addEventListener("message", (e) => {
// broadcast to all connected pages
connections.forEach(p => p.postMessage(e.data));
});
port.start();
});
Use cases:
- Shared state across tabs — auth status, user profile.
- Single WebSocket serving multiple tabs (instead of one WS per tab).
- Shared cache of computed data.
Browser support: not Safari (until recently — verify). Plan fallback to regular workers + BroadcastChannel.
Q: SharedWorker vs ServiceWorker — different things.
A: Both run in the background but:
- SharedWorker — for shared compute across tabs. Direct messaging via
MessagePort. No fetch interception. - ServiceWorker — for network interception and offline caching. Tied to a scope, not to specific tabs.
Use SharedWorker for “one calculation engine across N tabs”; ServiceWorker for “intercept network requests.”
BroadcastChannel Q&A
Q: When use BroadcastChannel?
A: Tabs of the same origin need to talk to each other. Simpler than SharedWorker; broader browser support.
const ch = new BroadcastChannel("auth");
// Tab A: user logs out
ch.postMessage({ type: "logout" });
// Tab B (and any other listening tab)
ch.addEventListener("message", (e) => {
if (e.data.type === "logout") {
location.reload(); // or rerun auth
}
});
ch.close(); // when done
Pure pub/sub: no shared compute, no shared state. For complex scenarios, SharedWorker. For “tab A told tab B to do X,” BroadcastChannel.
Use cases:
- Logout / login sync.
- “New message arrived” → other tabs update their unread badge.
- Theme change in one tab → others update.
Combined patterns
Q: Single WebSocket for multiple tabs — how?
A: SharedWorker hosts the socket; each tab connects to the SharedWorker:
Tab A ─┐
Tab B ─┼─── SharedWorker ─── WebSocket ─── server
Tab C ─┘
Saves server connections (1 vs N). The SharedWorker fans out received messages to all connected tabs via MessagePort.
Alternative: ServiceWorker holds the connection. More complex; only works if the SW is active.
Q: Multiple workers — pool pattern.
A: For parallel work across CPU cores:
const POOL_SIZE = navigator.hardwareConcurrency ?? 4;
const workers = Array.from({ length: POOL_SIZE }, () => new Worker(...));
let next = 0;
function nextWorker() {
const w = workers[next];
next = (next + 1) % workers.length;
return w;
}
async function process(item: Item) {
return new Promise((resolve) => {
const w = nextWorker();
w.onmessage = (e) => resolve(e.data);
w.postMessage(item);
});
}
For load balancing, route to the least-busy worker (track per-worker pending count) rather than round-robin. Libraries like threads.js or workerpool handle this.
Gotchas / edge cases
- Worker bundling — bundler needs to understand
new Worker(new URL(...)). Verify with Vite/Webpack 5; older setups need plugins. - Worker debugging in DevTools — open “More tools → Threads” panel; each worker shows as a separate context.
Transferableafter transfer — the sourceArrayBuffer.byteLengthis 0. Attempting to read it throws.- Workers and memory — workers have separate memory; large data goes through structured clone or transfer. 100MB transferred is fast; 100MB cloned is slow + doubles memory.
- Worker error events — uncaught errors in a worker fire
erroron the worker object. Wire to your error tracker. SharedWorkerdebugging —chrome://inspect/#workerslists active workers across tabs.importScripts(...)is the legacy synchronous module loader for workers (pre-ESM). Use{ type: "module" }+ ESMimportinstead.- WebGPU / OffscreenCanvas on workers — workers can hold an
OffscreenCanvasand render on it; common pattern for game/visualization engines.
What a senior is expected to say
- “Web Workers for CPU-intensive work that blocks the main thread. No DOM, communicates via postMessage. Use for parsing, encryption, ML inference, anything > ~50ms.”
- “Transferable objects move ownership zero-copy — critical for large binary. Without Transferable, structured clone copies + slows.”
- “Use Comlink (or similar) for typed RPC over postMessage — raw message handling scales poorly.”
- “SharedWorker for state/compute shared across tabs; BroadcastChannel for simple tab-to-tab pub/sub. Both same-origin only.”
- “Pool size =
navigator.hardwareConcurrency. Route work to workers via round-robin or least-busy.” - “Don’t put work in a worker if it’s fast — postMessage overhead isn’t worth it for sub-16ms tasks.”
Cross-references
- Event loop (workers have separate event loops): 01_event_loop.md
- INP and long-task budgets: ../15_performance/01_core_web_vitals.md
- Service Workers (the other “worker” — for network): 05_service_workers_pwa.md
- Storage in workers (IndexedDB works): 04_storage_apis.md
Further reading
- MDN — Web Workers: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
- Comlink: https://github.com/GoogleChromeLabs/comlink
- MDN — SharedWorker: https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker
- MDN — BroadcastChannel: https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
- web.dev — Use web workers to run JavaScript off the browser’s main thread: https://web.dev/articles/off-main-thread