Web Workers Architecture & Communication

An architectural reference for isolating heavy computation from the UI thread. This guide establishes explicit communication channels and enforces thread-boundary safety in production JavaScript environments — for frontend engineers who need the main thread free for layout, paint, and input while real work happens elsewhere. Everything here is the substrate the rest of this site builds on: the workload recipes in High-Performance Computation Patterns and the tooling workflows in Debugging, Profiling & Production Optimization both assume the boundary rules described below.

The Web Worker thread boundary The main thread holds the DOM and a single event loop; worker threads each own an isolated heap and event loop. Data crosses the boundary only by structured clone or by transferring ownership of a buffer. Main thread DOM · layout · paint · input Single event loop (16ms frame budget) UI heap · render tree new Worker(url) postMessage() · onmessage structured clone (copy) transfer list (zero-copy) Worker A isolated heap own event loop · no DOM Worker B isolated heap own event loop · no DOM
Each worker is a separate V8 isolate. Nothing is shared by default — data is either copied (structured clone) or handed over (transfer list).

Execution Contexts and the API Surface

Before writing any worker code, fix the mental model: a browser tab is not one runtime with several call stacks. It is a set of agents, each with its own heap, its own event loop, and its own microtask queue. The specification calls these agents; V8 calls them isolates. They share no variables, no prototypes, and no garbage-collection cycles. The only things that cross an agent boundary are serialized values, transferred objects, and — under strict conditions — pages of shared memory.

Four context types matter for background processing, and picking the wrong one is the most common architectural mistake on this topic:

Context DOM access Lifetime Created with Use it for
Main thread Full Tab lifetime Layout, paint, input, orchestration
Dedicated worker None Owned by its creator new Worker(url) CPU-bound computation for one page
Shared worker None Shared by same-origin documents new SharedWorker(url) One connection or cache serving several tabs
Service worker None Event-driven, may be killed anytime navigator.serviceWorker.register() Network interception, caching, offline

A service worker is not a compute thread. The browser is free to terminate it between events, so a long-running numeric loop inside one is an invitation to have your result discarded halfway. Dedicated workers are the default choice for computation; the trade-off matrix is worked through in Dedicated vs Service Workers for CPU Tasks.

Inside a dedicated worker, self is a DedicatedWorkerGlobalScope rather than a Window. Most of what you reach for still exists — fetch, WebSocket, IndexedDB, crypto.subtle, performance, URL, TextDecoder, WebAssembly, timers, and OffscreenCanvas. What is missing is everything tied to the document: window, document, localStorage, alert, and any DOM node. That absence is the point. A worker cannot cause a style recalculation, so it cannot cause jank.

The API surface you actually program against is small:

Member Side Purpose
new Worker(url, options) Main Spawn an isolate; { type: 'module' } enables static import
postMessage(data, transfer) Both Enqueue a message; the second argument hands over ownership
onmessage / message event Both Receive a deserialized payload on the target’s event loop
onmessageerror Both Fires when an incoming payload cannot be deserialized
onerror Both Uncaught synchronous error inside the worker script
terminate() Main Kill the isolate immediately, dropping pending tasks
self.close() Worker Voluntary shutdown after the current task completes
importScripts(...urls) Worker Synchronous dependency loading in classic (non-module) workers
The four execution contexts compared A four-row matrix. The main thread has full DOM access, lives as long as the tab and blocks paint. A dedicated worker has no DOM, is owned by its creator and is the best fit for CPU work. A shared worker has no DOM, lives until its last client closes and is contended. A service worker has no DOM, is event-driven, may be killed by the browser at any moment and is therefore unsuitable for computation. Which context should run the work? DOM access Lifetime Killed mid-task? Fit for CPU work Main thread the document agent itself Full The tab No Blocks paint Dedicated worker new Worker(url) None Its creator No Best fit Shared worker new SharedWorker(url) None Last client closes No Contended Service worker serviceWorker.register() None Event-driven Yes, anytime Unsuitable
The fourth column follows from the third: a context the browser may kill between events cannot be trusted with a computation you intend to finish.

Core Architecture & Thread Boundaries

Web Workers enforce strict memory partitioning between the main thread and background contexts. Each worker receives an independent isolate with its own heap and event loop. That divergence is what prevents a long-running script from blocking the rendering pipeline: while the worker’s loop is saturated, the main thread’s loop keeps servicing input, animation frames, and paint.

Isolation also means garbage collection never crosses the boundary. A major GC pause inside a worker stalls only that worker. This is a frequently underrated benefit: a main thread that allocates 200 MB of intermediate parse results will pay for it in stop-the-world pauses that show up directly as dropped frames, whereas the same allocation churn inside a worker is invisible to the compositor.

Thread-boundary enforcement relies exclusively on postMessage and onmessage. Direct object references cannot cross. The browser serializes the payload, copies it into the target heap, and reconstructs the object graph on the receiving side. Functions, DOM nodes, class identity, prototype chains, and getters do not survive that round trip — only data does.

Deployment strategy determines initialization latency and cache behaviour. Choosing between Inline Workers vs Dedicated Workers affects both: an inline worker built from a Blob URL skips a network round trip and ships inside your main bundle, but it forfeits a separate HTTP cache entry and re-parses on every page load. A separate worker file caches independently and can be preloaded, at the cost of one more request on a cold start. When your build tool needs to see the worker to emit it as its own chunk, follow the resolution rules in Bundling Module Workers with Vite and webpack.

Shared memory models require explicit cross-origin isolation headers. Without them, browsers disable SharedArrayBuffer outright to close Spectre-class side-channel attacks that a shared high-resolution timer would otherwise enable.

COOP / COEP required for shared memory

To unlock SharedArrayBuffer and Atomics, serve the document with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without cross-origin isolation, SharedArrayBuffer is simply undefined in the worker — covered in depth under SharedArrayBuffer & Atomics.

What crosses one agent boundary, and what stops at it A dashed vertical boundary separates the main-thread agent from the worker agent. Three things cross it: plain data structures arrive as an independent deep copy via structured clone; an ArrayBuffer listed in the transfer array arrives as the same bytes with the sender detached; a SharedArrayBuffer is mapped into both agents at once. Three things never cross: functions and closures throw DataCloneError, DOM nodes do not exist in the worker scope, and prototype chains are dropped so class identity is lost. MAIN-THREAD AGENT agent boundary WORKER AGENT Crosses — copied, handed over, or mapped Plain data · Map · Set · Date cycles in the graph are preserved structured clone An independent deep copy cost tracks node count, not bytes ArrayBuffer · ImageBitmap listed in the transfer array ownership hand-off The same bytes, new owner sender detached: byteLength 0 SharedArrayBuffer needs COOP + COEP one region, two maps Both agents map one region ordering is yours, via Atomics Stops at the boundary — nothing arrives on the far side Functions · closures no code crosses an agent postMessage() throws DataCloneError DOM nodes · window document is not defined there a worker cannot touch the render tree Prototypes · class identity fields survive, methods do not instanceof fails on the far side
Garbage collection is bounded by the same line: a stop-the-world pause inside one agent never stalls the other, which is half the reason offloading allocation-heavy parsing works at all.

A Minimal, Complete Worker Round-Trip

The smallest useful worker integration is not worker.postMessage('hi'). It is a typed protocol with correlated request and response ids, a zero-copy payload, and error propagation that cannot silently swallow a failure. Three files: a shared protocol, a main-thread client, and the worker itself.

// protocol.ts — imported by BOTH threads so the message shapes cannot drift apart.
export type Request =
  | { id: number; kind: 'sum'; payload: Float64Array }
  | { id: number; kind: 'drain' };

export type Response =
  | { id: number; ok: true; value: number }
  | { id: number; ok: false; error: SerializedError };

export interface SerializedError {
  name: string;
  message: string;
  stack?: string;
}
// client.ts — main thread
import type { Request, Response } from './protocol';

// `new URL(..., import.meta.url)` resolves against THIS module, not the document,
// and is the form Vite, webpack 5 and Rollup statically detect to emit a chunk.
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });

let nextId = 0;
const pending = new Map<number, { resolve(v: number): void; reject(e: Error): void }>();

worker.addEventListener('message', (event: MessageEvent<Response>) => {
  const msg = event.data;
  const entry = pending.get(msg.id);
  if (!entry) return;               // a late reply after a timeout — drop it silently
  pending.delete(msg.id);
  if (msg.ok) entry.resolve(msg.value);
  else entry.reject(Object.assign(new Error(msg.error.message), msg.error));
});

// Fires only for UNCAUGHT errors in the worker script: a parse error, a bad import,
// a top-level throw. It never fires for rejected promises inside the worker.
worker.addEventListener('error', (event) => {
  const err = new Error(`worker failed: ${event.message}`);
  for (const entry of pending.values()) entry.reject(err);
  pending.clear();
});

// Fires when a message arrived but could not be deserialized in this context —
// almost always a value the structured clone algorithm cannot reconstruct.
worker.addEventListener('messageerror', () => {
  console.error('undeserializable message from worker');
});

export function sum(samples: Float64Array): Promise<number> {
  const id = nextId++;
  return new Promise<number>((resolve, reject) => {
    pending.set(id, { resolve, reject });
    const request: Request = { id, kind: 'sum', payload: samples };
    // Transferring the backing buffer costs the same for 1 KB and 100 MB.
    // `samples` is detached here afterwards: samples.length becomes 0.
    worker.postMessage(request, [samples.buffer]);
  });
}
// worker.ts
import type { Request, Response, SerializedError } from './protocol';

const ctx = self as unknown as DedicatedWorkerGlobalScope;

function reply(message: Response): void {
  ctx.postMessage(message);
}

// Error instances DO structured-clone in modern engines, but `stack` is not
// guaranteed to survive and custom fields are dropped. Flatten deliberately.
function serializeError(err: unknown): SerializedError {
  const e = err instanceof Error ? err : new Error(String(err));
  return { name: e.name, message: e.message, stack: e.stack };
}

ctx.addEventListener('message', (event: MessageEvent<Request>) => {
  const msg = event.data;
  try {
    if (msg.kind === 'drain') {
      reply({ id: msg.id, ok: true, value: 0 });
      ctx.close();                  // ends the isolate after this task settles
      return;
    }
    let total = 0;
    for (let i = 0; i < msg.payload.length; i++) total += msg.payload[i];
    reply({ id: msg.id, ok: true, value: total });
  } catch (err) {
    reply({ id: msg.id, ok: false, error: serializeError(err) });
  }
});

// Promise rejections never reach `onerror`. Without this listener, an async
// failure inside the worker is invisible to the page.
ctx.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
  event.preventDefault();
  reply({ id: -1, ok: false, error: serializeError(event.reason) });
});
One correlated request and response across the boundary A sequence between the main-thread client and the worker. Request id 7 is posted with the sample buffer in the transfer list, which detaches it on the sending side; the worker runs the sum on its own event loop and replies with the same id, so the client resolves exactly the promise that issued the call. Request id 8 throws inside the worker, is flattened into a plain error object and returned as a normal reply — worker.onerror never sees it, because that handler only fires for uncaught script errors. Main thread — client.ts Worker — worker.ts postMessage({ id: 7, kind: 'sum' }, [samples.buffer]) transfer list — samples.length is 0 on this side from here on the worker loop is busy, the UI thread is not postMessage({ id: 7, ok: true, value }) pending.get(7).resolve(value) — the id, not the order, picks the promise postMessage({ id: 8, kind: 'sum' }, [other.buffer]) throw inside the task, caught then flattened postMessage({ id: 8, ok: false, error }) worker.onerror never fires for this — it only sees uncaught script errors
The failure travels the same channel as the result. Nothing about a thrown exception crosses the boundary by itself, so an error that is not deliberately serialized and posted is an error the page never learns about.

Three details in that listing carry disproportionate weight. The id correlation makes concurrency safe — without it, two overlapping calls to sum() can resolve with each other’s results, because message ordering is guaranteed per port but result association is not. The transfer argument turns an O(n) copy into an O(1) pointer hand-off. And the pair of error listeners closes the two independent failure channels: synchronous throws and rejected promises are reported through entirely different mechanisms in the worker global scope.

Lifecycle Management & Execution Contexts

Worker bootstrapping is not free. A cold new Worker() costs a network fetch (or blob decode), script parse, and isolate initialization — typically 5–15 ms on desktop and 20–60 ms on a mid-tier phone, before your first byte of work runs. That is why pools exist, and why spawning a worker per user keystroke is an anti-pattern: you can easily spend more time creating isolates than computing.

The lifecycle has four observable states, and the transitions between them are where bugs hide. A worker starts uninitialized, becomes running once its top-level script finishes evaluating, is draining while it settles in-flight work after a shutdown request, and is finally terminated. Messages posted before the worker’s script has evaluated are not lost — they queue on the port and are delivered once a message listener exists — but messages posted after terminate() vanish without error. Working through the Main Thread vs Worker Thread Lifecycle in detail exposes the synchronization windows that this state list only summarizes.

terminate() is a hard kill. It does not run cleanup code, does not flush pending microtasks, does not settle promises, and does not give the worker a chance to release native resources like a WebAssembly.Memory or an open IndexedDB transaction. Anything you needed the worker to finish must be finished before you call it. That is what a drain protocol is for: request a drain, let the worker settle its outstanding tasks and call self.close() itself, and keep a timeout so a wedged worker is still force-terminated rather than leaked. In single-page apps, tying that drain to route changes and component teardown is the difference between a stable memory profile and a slow leak of isolates — see Handling Worker Termination Gracefully in SPAs.

// lifecycle.ts — drain-then-terminate with a hard deadline
type State = 'idle' | 'running' | 'draining' | 'terminated';

const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
let state: State = 'idle';

worker.addEventListener('message', ({ data }) => {
  if (data.type === 'READY') state = 'running';
  if (data.type === 'DRAIN_COMPLETE' && state === 'draining') {
    worker.terminate();            // idempotent; the worker may already have closed
    state = 'terminated';
  }
});

export async function shutdownWorker(deadlineMs = 250): Promise<void> {
  if (state === 'terminated') return;
  state = 'draining';
  worker.postMessage({ type: 'DRAIN_REQUEST' });

  // Race the cooperative shutdown against a deadline. A worker stuck in a
  // synchronous loop can never answer, so the fallback must be unconditional.
  await new Promise<void>((resolve) => setTimeout(resolve, deadlineMs));
  if (state !== 'terminated') {
    worker.terminate();
    state = 'terminated';
  }
}
// worker.js — the cooperative half of the drain protocol
const pending = new Set();

self.postMessage({ type: 'READY' });

self.onmessage = async ({ data }) => {
  if (data.type === 'DRAIN_REQUEST') {
    // allSettled, not all: one rejected task must not abort the drain.
    await Promise.allSettled([...pending]);
    self.postMessage({ type: 'DRAIN_COMPLETE' });
    self.close();
    return;
  }
  const task = handleTask(data);
  pending.add(task);
  task.finally(() => pending.delete(task));
};

self.onerror = (event) => {
  self.postMessage({ type: 'ERROR', message: event.message });
};
The four observable states of a worker A worker moves from uninitialized to running once its top-level script evaluates, to draining on a DRAIN_REQUEST while in-flight tasks settle, and to terminated when the drain completes. Two escape hatches bypass the cooperative path: calling terminate() from the running state is a hard kill that drops pending tasks without cleanup, and a shutdown deadline forces termination when a wedged worker never answers. terminate() — hard kill: pending tasks dropped script evaluates DRAIN_REQUEST drain complete Uninitialized new Worker(url) issued script not evaluated yet Running top-level script settled message loop servicing Draining in-flight tasks settling no new work accepted Terminated isolate released posted messages vanish deadline fires → force terminate() messages posted here queue on the port
Only the left-hand transition is lossless. Messages posted before evaluation wait on the port; anything still queued when a hard kill lands is discarded without an error.

Communication Protocols & Data Serialization

The structured clone algorithm governs every cross-thread data exchange that is not a transfer or shared memory. It is richer than JSON.stringify: it preserves Map, Set, Date, RegExp, Blob, File, ArrayBuffer, typed arrays, and — importantly — cyclic references within the graph. It rejects functions, symbols, DOM nodes, and anything holding a closure, throwing a DataCloneError synchronously from postMessage.

Value Clones? Notes
Plain objects, arrays, primitives Yes Prototype is discarded; the result is a plain object
Map, Set, Date, RegExp Yes Reconstructed as real instances in the target
ArrayBuffer, typed arrays, DataView Yes Copied unless listed in the transfer list
Cyclic object graphs Yes Cycles are preserved, unlike JSON
Error Yes name/message survive; stack and custom fields are unreliable
Class instances Partly Fields survive, prototype and methods do not
Functions, symbols, DOM nodes No Throws DataCloneError
SharedArrayBuffer Shared Not copied and not transferred — both agents map the same pages

Cost scales with the size and shape of the graph, not just its byte count: deep graphs with many small objects serialize far more slowly per byte than a single large ArrayBuffer. As a working figure, a 10 MB structured clone costs on the order of 12–18 ms on each side on a mid-range laptop, and that time is spent on the posting thread — so a careless clone on the main thread produces exactly the jank you moved work off-thread to avoid. Measuring it on your own payloads takes about ten lines; the method is in Measuring Structured Clone Cost with performance.now(), and the algorithm’s edge cases are walked through in the Step-by-Step Guide to the Structured Clone Algorithm.

High-throughput architectures need batching. Amortizing per-message overhead across many small payloads is the single highest-leverage change in a chatty worker integration: a sliding-window flush aligned to the 16 ms display frame keeps the pipeline inside the frame budget while cutting message count by one to two orders of magnitude. The batching, fan-out, and request/response shapes are catalogued in Message Passing Strategies.

For anything binary and large, stop copying. Passing an ArrayBuffer in the transfer list detaches it from the sender and remaps it into the receiver — constant time regardless of size. Image data, audio frames, WebGL vertex buffers, and decoded columnar data should always move this way, as detailed in Transferable Objects & Zero-Copy.

MessageChannel gives you a second axis. Each channel is a pair of MessagePort objects, and a port is itself transferable — so the main thread can mint a private channel, hand one end to worker A and the other to worker B, and let them talk directly without relaying through the UI thread. Dedicating a channel per logical stream also prevents head-of-line blocking: a slow bulk transfer on one port cannot delay a small control message on another.

// backpressure.ts — credit-based flow control over a dedicated channel
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
const channel = new MessageChannel();

// Hand port2 to the worker; port1 stays here. Ports must be transferred, not cloned.
worker.postMessage({ type: 'BIND_PORT', port: channel.port2 }, [channel.port2]);

const MAX_CREDITS = 8;             // bounded queue depth: memory stays predictable
let credits = MAX_CREDITS;

channel.port1.onmessage = ({ data }) => {
  if (data.type === 'ACK') credits = Math.min(MAX_CREDITS, credits + data.released);
};

/** Returns false when the consumer is saturated; the caller should retry later. */
export function sendChunk(chunk: ArrayBuffer): boolean {
  if (credits <= 0) return false;  // backpressure — do NOT queue unbounded work
  credits--;
  channel.port1.postMessage(chunk, [chunk]);
  return true;
}
// worker.js — the consumer half: acknowledge only after the work is done
self.onmessage = ({ data }) => {
  if (data.type !== 'BIND_PORT') return;
  const port = data.port;

  port.onmessage = (event) => {
    processChunk(new Uint8Array(event.data));
    // ACK after processing, never on receipt — acknowledging early
    // reintroduces the unbounded queue the credits exist to prevent.
    port.postMessage({ type: 'ACK', released: 1 });
  };
  port.start();
};
Unbounded posting versus credit-based flow control Two lanes. In the first, a producer posts as fast as it can and every unprocessed message piles up in the receiving queue, which nothing bounds, so memory grows until the tab is killed. In the second, the producer holds a fixed number of credits over a dedicated MessagePort, spends one per chunk, and only gets a credit back when the consumer acknowledges after the work is done — so the number of in-flight chunks, and therefore the memory they occupy, has a ceiling. Unbounded postMessage() the receiving queue has no ceiling Producer main thread no ceiling on sends Consumer worker one chunk at a time every unprocessed message stays resident — the tab is eventually killed Credit-based flow control over a dedicated MessagePort Producer credits = 8 max sendChunk() returns false at zero port1.postMessage(chunk, [chunk]) — only while credits > 0 ACK after the work is done, never on receipt Consumer processChunk() queue depth ≤ MAX_CREDITS 5 credits held · 3 in flight in-flight work ≤ MAX_CREDITS × chunk size
Acknowledging on receipt instead of on completion silently restores the top lane: the credits come back faster than the work drains, and the queue is unbounded again.

Performance Envelope

Workers are a latency trade, not free speed. You pay a fixed cost per message and a variable cost per byte, and you win only when the work you moved exceeds both. These are the numbers worth memorizing, drawn from typical desktop Chromium behaviour on mid-range hardware — always re-measure on your own targets.

Operation Typical cost Scales with
new Worker() cold start (module) 5–15 ms desktop, 20–60 ms mobile Script size, import graph depth
Empty postMessage round trip 0.1–0.5 ms Event-loop occupancy on both sides
Structured clone, small object (<10 KB) < 0.1 ms Node count in the graph
Structured clone, 1 MB typed array ~1–2 ms per side Byte count
Structured clone, 10 MB object graph 12–18 ms per side Byte count and node count
ArrayBuffer transfer, any size < 0.1 ms Nothing — constant time
SharedArrayBuffer read/write Memory-speed Nothing — no boundary crossing

From that table three rules follow. First, if a task takes less than about 4 ms synchronously, leave it on the main thread; the round trip will cost more than the work. Second, if the payload is binary and larger than roughly 100 KB, transfer it rather than clone it — the crossover where transfer clearly wins arrives early and never reverses. Third, if the payload is a deep object graph, the cheapest optimization is usually to flatten it into a typed array or a single encoded string before it crosses, because clone cost tracks node count as much as byte count.

Situation Use Avoid
One heavy computation, results needed once Dedicated worker, transfer the result buffer Spawning a pool for a single job
Many independent jobs, CPU-bound Pool sized near hardwareConcurrency One worker per job
Continuous stream (audio, telemetry, parse chunks) Dedicated MessagePort + credit-based backpressure Unbounded postMessage fan-out
Multiple readers of one large dataset SharedArrayBuffer under cross-origin isolation Cloning the dataset per worker
Frequent small DOM-adjacent updates Main thread, batched into one frame A worker per widget

The decision between a copy, a hand-off, and shared memory is the one that most changes an architecture, and it is worth resolving explicitly rather than by default — postMessage vs SharedArrayBuffer: When to Choose Each works through the full decision path with measurements.

Choosing between keeping, cloning, transferring and sharing A three-question decision path. If the task finishes in under about four milliseconds synchronously, keep it on the main thread. Otherwise, if the payload is a plain object graph rather than raw bytes, structured clone it after flattening deep graphs. Otherwise, if several agents must read the same bytes at the same time, use SharedArrayBuffer with Atomics under cross-origin isolation. If none of those apply, transfer the buffer: a constant-time hand-off that detaches the sender. Copy it, hand it over, or share it? Does the task finish in under about 4 ms synchronously? YES Keep it on the main thread the round trip costs more than the work you moved NO Is the payload a plain object graph rather than raw bytes? YES Structured clone it flatten deep graphs first — cost tracks node count NO Do several agents need to read the same bytes at once? YES SharedArrayBuffer + Atomics needs COOP/COEP; the memory ordering is yours NO Transfer the buffer postMessage(msg, [buf]) — O(1), sender detached Reference costs clone < 10 KB ≈ 0.1 ms · 1 MB ≈ 1–2 ms per side transfer < 0.1 ms at any size · shared: no crossing
Only the first question is about time; the rest are about the shape of the bytes. Answer them in this order and the transfer strategy falls out without measurement.

Security, Isolation & Browser Support

Workers inherit the origin of the document that created them and are bound by the same-origin policy, but they add their own constraints. The worker-src (falling back to child-src, then default-src) CSP directive governs which URLs may be used to construct a worker. A strict policy that omits blob: will silently break every inline worker built from URL.createObjectURL() — the constructor still returns an object, and the failure surfaces asynchronously as a SecurityError on worker.onerror. If you rely on inline workers, allow worker-src 'self' blob: deliberately rather than by accident.

Cross-origin worker scripts are refused outright: new Worker('https://cdn.example.com/w.js') throws, regardless of CORS headers. The standard workaround is to fetch the script text yourself and instantiate a blob worker from it, which is also why bundlers that inline worker code produce more portable output than ones that emit absolute CDN URLs.

Cross-origin isolation is a page-wide contract

Turning on Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp unlocks SharedArrayBuffer, but it also breaks every third-party iframe, image, script, and font that does not send Cross-Origin-Resource-Policy or valid CORS headers. Audit embeds — ad tags, analytics pixels, video players, map widgets — before enabling it, and verify with self.crossOriginIsolated === true at runtime rather than assuming the headers arrived.

Engine behaviour still differs in ways that reach production. Module workers ({ type: 'module' }) shipped in Chrome 80 but only reached Firefox in 114 and Safari in 15, so any project supporting older browsers needs a classic-worker fallback that uses importScripts() instead of static import. OffscreenCanvas landed in Safari only in 16.4. Atomics.waitAsync — the non-blocking wait usable on the main thread — remains Chromium-only at the time of writing, so cross-browser coordination must fall back to postMessage notification. And performance.memory is a non-standard Chromium extension: treat it as a diagnostic hint, never as a gauge you make decisions on.

Feature Chrome Firefox Safari Edge
Dedicated Worker 4+ 3.5+ 4+ 12+
Module workers (type: 'module') 80+ 114+ 15+ 80+
Transferable ArrayBuffer 17+ 18+ 5.1+ 12+
MessageChannel / MessagePort 4+ 41+ 5+ 12+
SharedArrayBuffer (isolated) 68+ 79+ 15.2+ 79+
Atomics.waitAsync 87+ No No 87+
OffscreenCanvas 69+ 105+ 16.4+ 79+
SharedWorker 4+ 29+ 16+ 79+

Feature-detect rather than sniff versions: typeof SharedArrayBuffer !== 'undefined' && self.crossOriginIsolated for shared memory, typeof OffscreenCanvas !== 'undefined' for off-thread rendering, and a try/catch around a throwaway module-worker construction if you need to branch on module support.

What cross-origin isolation unlocks and what it breaks Turning on Cross-Origin-Opener-Policy same-origin together with Cross-Origin-Embedder-Policy require-corp unlocks SharedArrayBuffer, the Atomics wait and notify family, zero-copy sharing between agents and precise memory measurement. The same switch breaks every third-party iframe without a Cross-Origin-Resource-Policy header, images fonts and scripts without CORS, ad tags and analytics pixels, and embedded video or map widgets. Cross-origin isolation ON COOP: same-origin + COEP: require-corp What it unlocks What it breaks SharedArrayBuffer is defined Atomics.wait, notify and waitAsync Zero-copy sharing between agents measureUserAgentSpecificMemory() Third-party iframes without CORP Images, fonts and scripts without CORS Ad tags and analytics pixels Embedded video and map widgets Verify at runtime with self.crossOriginIsolated === true rather than assuming the headers arrived
Both columns arrive together — the header pair is a page-wide contract, not a worker-level flag, so audit the right-hand column before you reach for the left.

Advanced Patterns for Production Workloads

Three extensions separate a demo integration from one that survives a real workload: sizing a pool, bounding the queues between threads, and recycling isolates before they degrade.

Pool sizing and dispatch

A pool amortizes the 5–15 ms start-up cost across many jobs and caps concurrency at something the OS scheduler can actually service. Size it at navigator.hardwareConcurrency, minus one when the main thread is also doing meaningful work during the job — the UI thread needs a core too. Add at most one overflow worker for sustained spikes, and scale back after an idle period, since each idle isolate still holds 2–8 MB resident. Beyond physical core count, throughput flattens while context-switching overhead keeps climbing. Whether that pool should be fixed or elastic depends on the arrival pattern of your jobs; the two designs are compared under Worker Pool Management and, in more depth, in Dynamic vs Fixed-Size Worker Pools.

Thread affinity matters more than most teams expect. Routing structurally similar jobs to the same worker keeps its inline caches warm and its JIT tiers hot; shuffling job types across workers forces repeated deoptimization. When your tasks come in distinct shapes, prefer a small pool per shape over one heterogeneous pool.

Credit-based backpressure

Unbounded postMessage is a memory leak with extra steps. Every posted message that the receiver has not yet processed sits in a queue that nothing bounds, so a producer faster than its consumer will grow that queue until the tab is killed. The credit scheme shown earlier fixes this by making capacity explicit: the producer may only send while it holds credits, and credits return only after work completes. A cap of hardwareConcurrency * 2 in-flight items is a sane starting point for compute pipelines.

Isolate recycling

Long-lived workers that churn through large buffers fragment their heaps. V8 can reclaim fragmented pages only so far, and GC pause spikes creep upward over hours in a dashboard or editor that never reloads. Recycling a worker — drain, terminate, respawn — every N jobs or every M megabytes processed resets the heap to a clean state for the price of one cold start. Instrument first: if your heap-size trend across a long session is flat, recycling buys nothing. The heap-diffing workflow that tells you which it is lives under Debugging, Profiling & Production Optimization.

The pool below combines all three ideas: bounded concurrency, transfer-aware dispatch, per-worker in-flight tracking, and replacement of workers that die.

// pool.ts — a transfer-aware worker pool with correct in-flight accounting
interface Task {
  id: number;
  payload: ArrayBuffer;
  resolve(result: ArrayBuffer): void;
  reject(error: Error): void;
}

export class WorkerPool {
  private readonly workers = new Set<Worker>();
  private readonly idle: Worker[] = [];
  private readonly inflight = new Map<Worker, Task>();   // one slot per worker
  private readonly queue: Task[] = [];
  private nextId = 0;
  private jobsRun = 0;

  constructor(
    private readonly url: URL,
    private readonly size = Math.max(1, (navigator.hardwareConcurrency || 4) - 1),
    private readonly recycleAfter = 200,                 // jobs before a fresh isolate
  ) {
    for (let i = 0; i < this.size; i++) this.spawn();
  }

  private spawn(): void {
    const worker = new Worker(this.url, { type: 'module' });
    worker.addEventListener('message', (event: MessageEvent<{ buffer: ArrayBuffer }>) => {
      this.settle(worker, event.data.buffer);
    });
    // A worker that throws at top level is unusable: replace it, never reuse it.
    worker.addEventListener('error', (event) => {
      this.fail(worker, new Error(event.message));
    });
    this.workers.add(worker);
    this.idle.push(worker);
  }

  run(payload: ArrayBuffer): Promise<ArrayBuffer> {
    return new Promise<ArrayBuffer>((resolve, reject) => {
      this.queue.push({ id: this.nextId++, payload, resolve, reject });
      this.pump();
    });
  }

  private pump(): void {
    while (this.idle.length > 0 && this.queue.length > 0) {
      const worker = this.idle.pop()!;
      const task = this.queue.shift()!;
      this.inflight.set(worker, task);
      // Transfer the payload: the pool must not retain a reference afterwards.
      worker.postMessage({ id: task.id, buffer: task.payload }, [task.payload]);
    }
  }

  private settle(worker: Worker, buffer: ArrayBuffer): void {
    const task = this.inflight.get(worker);
    if (!task) return;                       // stray message, e.g. a progress ping
    this.inflight.delete(worker);
    this.jobsRun++;

    if (this.jobsRun % this.recycleAfter === 0) {
      this.retire(worker);                   // fresh heap, one cold start amortized
      this.spawn();
    } else {
      this.idle.push(worker);
    }
    task.resolve(buffer);
    this.pump();
  }

  private fail(worker: Worker, error: Error): void {
    this.inflight.get(worker)?.reject(error);
    this.inflight.delete(worker);
    this.retire(worker);
    if (this.workers.size < this.size) this.spawn();
    this.pump();
  }

  private retire(worker: Worker): void {
    worker.terminate();
    this.workers.delete(worker);
    const index = this.idle.indexOf(worker);
    if (index >= 0) this.idle.splice(index, 1);
  }

  /** Reject queued work, kill every isolate. Call this on teardown. */
  destroy(reason = 'pool destroyed'): void {
    for (const task of this.queue) task.reject(new Error(reason));
    this.queue.length = 0;
    for (const task of this.inflight.values()) task.reject(new Error(reason));
    this.inflight.clear();
    for (const worker of [...this.workers]) this.retire(worker);
  }
}

Note what the pool deliberately does not do: it never rebinds onmessage per task. Reassigning the handler on each dispatch is the classic bug in hand-rolled pools — handlers stack up, a late reply resolves the wrong promise, and error listeners get lost. Keeping one permanent listener per worker and one in-flight slot in a Map makes correlation explicit and leak-free. A working implementation of the same shape without TypeScript is in Implementing a Simple Worker Pool in Vanilla JS.

How the pool routes one task and recycles one worker Queued tasks are popped by pump() only while an idle worker exists. Each worker holds exactly one in-flight slot, tracked in a Map, so a reply can be correlated without rebinding the message handler. Completed buffers are transferred back to the caller. When a worker crosses its job budget it is retired and a replacement is spawned, which returns a worker with a fresh heap to the idle list. One in-flight slot per worker, one permanent listener queue: Task[] #4 · 8 MB #3 · 2 MB #2 · 12 MB #1 · next out run() pushes · pump() pops idle: Worker[] pump() idle × queued Worker 1 in-flight: task #12 Worker 2 in-flight: task #11 Worker 3 jobsRun hits the budget task.resolve (buffer) transferred back jobsRun % 200 === 0 retire() + spawn() → fresh heap a replacement joins the idle list
The Map from worker to in-flight task is what makes one permanent listener sufficient. Rebinding onmessage per dispatch is the classic hand-rolled-pool bug: handlers accumulate and a late reply resolves someone else's promise.

When a Worker Is the Wrong Tool

Offloading is not always the answer, and reaching for a worker reflexively adds latency, code, and failure modes for nothing. Skip it when the task is short (under about 4 ms), when it is dominated by DOM reads or writes that must happen on the main thread anyway, or when it is I/O-bound rather than CPU-bound — fetch already runs off-thread, so wrapping a network call in a worker buys you nothing but a round trip.

Two alternatives handle a surprising share of cases. Chunking a long loop across requestIdleCallback or scheduler.postTask() keeps the main thread responsive without any boundary crossing, and works well when the computation touches state that cannot be serialized. And for genuinely large binary workloads, the right question is often not “which thread” but “which representation” — moving from an array of objects to a columnar typed array can shrink both the compute time and the transfer cost by an order of magnitude before any concurrency is involved. When you do decide to offload, the workload-specific recipes — parsing, image filters, WebAssembly, off-thread rendering — are collected in High-Performance Computation Patterns, and the techniques for passing multi-megabyte payloads without a stutter are in How to Pass Large Arrays Without Blocking the UI.

When offloading pays, and when it does not A scale of synchronous blocking time. Below about four milliseconds the worker round trip costs more than the work, so keep it in place. Between four milliseconds and the sixteen-millisecond frame budget, chunking with scheduler.postTask is usually enough. Past the frame budget you are dropping frames and should move the work off-thread; past the fifty-millisecond input-response threshold it should be both off-thread and chunked. Three conditions disqualify a worker regardless of duration: I/O-bound work, DOM access, and state that cannot be serialized. How long does it block, and why? Measured synchronously with performance.now() on a mid-tier device Leave it here worker overhead wins Chunk it instead scheduler.postTask() Move it off-thread you are dropping frames Off-thread, chunked input latency is visible 0 ms ≈4 ms 16 ms — one frame 50 ms — INP budget Skip the worker entirely when: The work is I/O-bound fetch already runs off-thread It reads or writes the DOM that must happen on the main thread State cannot be serialized chunk it rather than move it
The scale answers only half the question. A task can sit far to the right and still belong on the main thread if it fails one of the three tests underneath.

Newer Ground in This Section

Two additions extend this reference past the raw platform APIs into the shape most applications actually need.

Comlink & RPC Patterns covers the layer that replaces hand-written message envelopes with typed remote procedure calls: how the proxy records a property path and turns it into one postMessage hop, how transfer lists and callbacks travel through it, and the round-trip budgeting that keeps a convenient abstraction from becoming a chatty one. It is the practical answer to the boilerplate that Message Passing Strategies describes building by hand — including a decision guide for when the hand-written envelope is still the better tool.

Framework Integration Patterns answers the question that arrives immediately afterwards: who owns the thread when the code lives in components. A worker has a session-length lifetime and a component has a render-length one, and every leak in this area comes from tying them together — twelve chart cards starting twelve threads, a development-mode remount doubling them, a result arriving for a view that no longer exists. It covers module-scoped ownership, cancellation on unmount, the reactivity costs that reappear when a large result lands, and the server-rendering guards that keep Worker out of Node.

Frequently Asked Questions

How do I prevent main-thread blocking during large data transfers?
Use transferable objects (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas) to bypass structured cloning — pass them in the second argument of postMessage. Transfer is a pointer hand-off, so cost is independent of payload size. For sustained streams, add chunked message passing with explicit backpressure so queue depth stays bounded and the receiving thread never accumulates an unbounded task backlog.
What is the optimal worker pool size for CPU-bound tasks?
Start with navigator.hardwareConcurrency, minus one if the main thread also does real work during the job. Add at most one overflow worker during sustained spikes. Exceeding physical core counts increases OS scheduling overhead without proportional throughput gains, and each extra worker costs 2–8 MB of resident memory for its isolate.
How are unhandled errors isolated between threads?
Workers run in isolated contexts, so exceptions never propagate into main-thread try/catch. Attach self.onerror for synchronous throws and self.addEventListener('unhandledrejection', …) for rejected promises, flatten the error into a plain object (Error instances do not structured-clone with a usable stack), and route it back over postMessage. The main thread’s worker.onerror only fires for uncaught script errors, never for rejections.
When should I use SharedArrayBuffer over message passing?
Reach for SharedArrayBuffer when multiple threads need concurrent reads of the same bytes, or when round-trip postMessage latency (roughly 0.1–0.5 ms per hop) is too coarse for your coordination protocol. It requires COOP/COEP cross-origin isolation headers and forces you to reason about memory ordering with Atomics. For one-way, high-throughput pipelines, a transferable ArrayBuffer is simpler and just as fast.
Why does my worker not start when I pass a relative path string?
A bare string is resolved against the document URL, not the module that created the worker, and bundlers cannot statically see it. Use new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) so the path resolves correctly and the bundler emits the worker as its own chunk. If the script 404s or violates the page’s worker-src CSP directive, the constructor still succeeds and the failure surfaces asynchronously on worker.onerror.

See also