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.
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 |
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.
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.
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) });
});
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 });
};
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();
};
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.
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.
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.
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.
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.
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.