frontend / browser internals / 06_web_workers.md

Web Workers, SharedWorker, BroadcastChannel

7 min read source

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.
  • Transferable after transfer — the source ArrayBuffer.byteLength is 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 error on the worker object. Wire to your error tracker.
  • SharedWorker debuggingchrome://inspect/#workers lists active workers across tabs.
  • importScripts(...) is the legacy synchronous module loader for workers (pre-ESM). Use { type: "module" } + ESM import instead.
  • WebGPU / OffscreenCanvas on workers — workers can hold an OffscreenCanvas and 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

Further reading