Worker Pool Management
A worker pool keeps a fixed set of pre-warmed background threads alive and feeds them a queue of tasks, instead of constructing and destroying a Worker per unit of work. This page is part of the Web Workers Architecture & Communication reference, and it covers the complete build: sizing the pool against the hardware, pre-warming instances off the critical rendering path, routing tasks to idle threads, prioritising urgent work, moving large payloads without copying them, measuring what the pool actually delivers, and recovering when a worker dies mid-task.
The trade-off a pool manages is narrow and physical. Too few workers and the queue backs up, so tasks sit waiting while cores idle. Too many and the operating system spends its quantum rotating threads that have no core to run on, so throughput flattens while memory climbs — every worker is a separate JavaScript isolate with its own heap, typically 2–6 MB before your code allocates anything.
pop(), so the hottest isolate runs first). Worker 1 is mid-task; the other three sit in the idle stack. Every reply pushes its worker back and re-runs the loop synchronously, so a core is never left idle while work is queued.The Bottleneck: Forty Cold Starts and One Serial Queue
The workload that forces a pool is easy to recognise. A photo grid uploads 40 images and each one needs an EXIF strip, a downscale and a perceptual hash — roughly 90 ms of pure computation per image on a mid-range laptop. Two naive implementations both fail, in opposite directions.
One worker per task. The page calls new Worker(...) forty times. Each construction costs 5–15 ms of main-thread time (module resolution, script fetch from the HTTP cache, V8 isolate creation and script evaluation), so 300–600 ms of jank lands on the main thread before any pixel work starts. Forty live isolates also mean 80–240 MB of baseline heap, and on a 4-core machine 36 of them are queued behind the OS scheduler anyway. On low-end Android the tab is a memory-kill candidate.
One long-lived worker. Construction cost disappears, but the 40 tasks now run strictly serially: 40 × 90 ms = 3.6 s of wall-clock time with three cores idle for the whole run. The UI stays smooth and the feature is still unusably slow.
A pool of hardwareConcurrency - 1 workers built once and reused resolves both: construction is paid once, off the critical path, and the 40 tasks spread across the available cores at roughly 3.5× the single-worker throughput on a 4-core machine. The observable symptom you are optimising away is the pair of numbers “600 ms of main-thread jank” and “3.6 s to finish” — and both are measurable before and after with the technique in Verification & Measurement below.
Worker construction is 5–15 ms per isolate on a warm HTTP cache and can exceed 40 ms on a cold cache over a slow network, because the script fetch is part of startup. Dispatching to an already-running pooled worker costs only the postMessage hop — about 0.05–0.15 ms for a small structured-clone payload. That ratio, roughly 100×, is the entire economic case for pooling.
Prerequisites
Before implementing the pool, confirm the following:
- A task that is genuinely CPU-bound. Pools do not help with network waits — those are already asynchronous on the main thread. Parsing, image processing, compression, crypto and geometry are the right shape; see Image Processing in Workers for a worked domain example.
- A stateless worker script. Pooled workers are reused across unrelated tasks, so any module-level mutable state leaks between callers. If the worker must cache something (a compiled WebAssembly module, a lookup table), that cache must be immutable or keyed.
navigator.hardwareConcurrencysupport (Chrome 37+, Firefox 48+, Safari 10.1+) with a fallback constant for older engines and for browsers that clamp the value for privacy — Safari reports a capped figure, and Firefox in Resist Fingerprinting mode reports2.- A stable worker URL your bundler understands.
new Worker(new URL('./task.worker.ts', import.meta.url), { type: 'module' })is the form Vite and webpack both statically analyse; Bundling Module Workers with Vite and webpack covers the config that emits one chunk per worker rather than inlining it. - Module worker support (Chrome 80+, Safari 15+, Firefox 114+) if you want static
importinside the worker; otherwise ship a classic worker and useimportScripts(). - COOP/COEP headers —
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp— only if the pool will share memory viaSharedArrayBuffer. Without both, the constructor isundefinedin every context on the page. Plain pools and transferable payloads need no special headers. - A grasp of the thread boundary itself. Main Thread vs Worker Thread Lifecycle explains what survives termination and what leaks if you skip it, which is what makes the drain protocol in Step 6 correct rather than hopeful.
Step-by-Step: Building a Production Worker Pool
Each step below is independently useful. Together they compose the WorkerPool class in Step 4, which is the version worth shipping.
Step 1 — Size the pool from the hardware
navigator.hardwareConcurrency reports logical cores, meaning hyper-threads. On a 4-core/8-thread laptop it returns 8, and eight compute-bound workers will fight over four physical execution units while the main thread also needs one. Reserve a thread and clamp both ends.
// pool-size.ts
export interface PoolSizeOptions {
min?: number; // never go below this, even on a reported 1-core device
max?: number; // ceiling: memory, not cores, is the binding constraint
reserve?: number; // threads left for the main thread + browser internals
}
export function recommendedPoolSize(opts: PoolSizeOptions = {}): number {
const { min = 2, max = 8, reserve = 1 } = opts;
// Undefined on old Safari/Edge; clamped to 2 by Firefox's resistFingerprinting.
const logical = navigator.hardwareConcurrency ?? 4;
// deviceMemory is Chromium-only and rounded to 0.25/0.5/1/2/4/8 (GB).
// Treat <= 2 GB as low-end and refuse to hold more than two isolates.
const memoryGb = (navigator as Navigator & { deviceMemory?: number }).deviceMemory;
const memoryCeiling = memoryGb !== undefined && memoryGb <= 2 ? 2 : max;
return Math.max(min, Math.min(memoryCeiling, logical - reserve));
}
Past hardwareConcurrency workers, throughput on CPU-bound work stops improving and total memory keeps rising — each isolate carries its own heap (2–6 MB idle) plus whatever your task allocates. The correct saturation signal is CPU utilisation near 100% across all cores in the Performance panel, not the worker count. If cores are busy and the queue is still deep, the fix is a faster task or a smaller payload, not more threads.
Step 2 — Pre-warm the workers off the critical path
Construction is synchronous main-thread work, so doing it during the initial render competes directly with Largest Contentful Paint. Defer it to idle time, and treat a worker as available only once it has answered a readiness ping — otherwise the first real task queues behind module evaluation and its measured latency includes startup.
// warmup.ts
type IdleFn = (cb: () => void) => void;
// requestIdleCallback is unsupported in Safari < 16; degrade to a macrotask.
const onIdle: IdleFn =
typeof requestIdleCallback === 'function'
? (cb) => requestIdleCallback(() => cb(), { timeout: 2000 })
: (cb) => setTimeout(cb, 0);
export function spawnReadyWorker(url: URL): Promise<Worker> {
return new Promise((resolve, reject) => {
onIdle(() => {
const worker = new Worker(url, { type: 'module' });
// The worker replies { type: 'READY' } at the end of its module body,
// which proves the script parsed and evaluated without throwing.
const onReady = (e: MessageEvent) => {
if (e.data?.type !== 'READY') return;
worker.removeEventListener('message', onReady);
worker.removeEventListener('error', onFail);
resolve(worker);
};
const onFail = (e: ErrorEvent) => {
worker.terminate();
reject(new Error(`Worker failed to start: ${e.message}`));
};
worker.addEventListener('message', onReady);
worker.addEventListener('error', onFail, { once: true });
worker.postMessage({ type: 'PING' });
});
});
}
// task.worker.js — the worker side of the readiness handshake
self.addEventListener('message', (e) => {
if (e.data?.type === 'PING') {
self.postMessage({ type: 'READY' });
return;
}
// ...task handling (Step 3)
});
Idle-time warm-up keeps startup off the critical path but means the pool may not be ready when the user's first interaction arrives — that request pays construction cost anyway. If the feature is the point of the page (an editor, a data grid), warm eagerly right after the load event instead; if it is incidental (an export button), warm lazily on first hover or first call. Never warm inside a click handler and then await readiness before starting the work: that serialises the two costs.
Step 3 — Key in-flight tasks by id, not by worker instance
Keying pending promises by Worker object works only while the mapping is strictly one task per worker and no reply ever arrives late. Task ids survive timeouts, cancellation and the day someone lets a worker handle two messages concurrently.
// protocol.ts — the wire contract between pool and worker
export type TaskRequest<T = unknown> = { id: number; type: 'RUN'; payload: T };
export type TaskResponse<R = unknown> =
| { id: number; status: 'OK'; result: R; workerMs: number }
| { id: number; status: 'ERR'; name: string; message: string; stack?: string };
let nextId = 0;
export const allocateTaskId = (): number => ++nextId;
// task.worker.js — echo the id back on every path, including failures
self.addEventListener('message', async (e) => {
const { id, type, payload } = e.data ?? {};
if (type !== 'RUN') return;
const started = performance.now();
try {
const result = await runTask(payload);
self.postMessage({ id, status: 'OK', result, workerMs: performance.now() - started });
} catch (err) {
// Error objects do not survive structured clone with their prototype or
// stack intact — flatten to plain fields before posting.
self.postMessage({
id,
status: 'ERR',
name: err?.name ?? 'Error',
message: err?.message ?? String(err),
stack: err?.stack
});
}
});
An id-keyed Map adds one hash lookup per reply — nanoseconds. What it buys is a pool that can drop a task (timeout, user cancel) without corrupting the next one: a late reply for id 41 finds no entry and is discarded, whereas an instance-keyed map would hand that stale result to whatever task is now running on the same worker. The failure is silent and near-impossible to reproduce, which is why it is worth pre-empting.
Step 4 — Drain the queue whenever a worker frees up
This is the pool itself: an idle stack, a pending queue and a dispatch loop that runs after every state change. The loop is deliberately synchronous — the moment a worker returns, the next task is posted in the same tick, so cores never idle waiting for a microtask.
// worker-pool.ts
import { recommendedPoolSize } from './pool-size';
import { allocateTaskId, type TaskResponse } from './protocol';
interface PendingTask<T, R> {
id: number;
payload: T;
transfer: Transferable[];
resolve: (value: R) => void;
reject: (reason: Error) => void;
enqueuedAt: number;
attempts: number;
}
export class WorkerPool<T = unknown, R = unknown> {
readonly #url: URL; // kept for crash-recovery respawn
readonly #size: number;
readonly #idle: Worker[] = [];
readonly #busy = new Map<Worker, PendingTask<T, R>>();
readonly #pending = new Map<number, PendingTask<T, R>>();
#queue: PendingTask<T, R>[] = [];
#destroyed = false;
readonly metrics = {
dispatched: 0,
completed: 0,
failed: 0,
respawned: 0,
maxQueueDepth: 0,
latencies: [] as number[] // total (queue wait + execution), ms
};
constructor(url: URL, size: number = recommendedPoolSize()) {
this.#url = url;
this.#size = size;
for (let i = 0; i < size; i++) this.#idle.push(this.#spawn());
}
#spawn(): Worker {
const worker = new Worker(this.#url, { type: 'module' });
worker.addEventListener('message', (e: MessageEvent<TaskResponse<R>>) =>
this.#onReply(worker, e.data)
);
worker.addEventListener('error', (e) => this.#onFatal(worker, e));
// Fired when a reply cannot be deserialised — distinct from 'error'.
worker.addEventListener('messageerror', () =>
this.#onFatal(worker, new ErrorEvent('error', { message: 'Reply failed structured clone' }))
);
return worker;
}
run(payload: T, transfer: Transferable[] = []): Promise<R> {
if (this.#destroyed) return Promise.reject(new Error('Pool destroyed'));
return new Promise<R>((resolve, reject) => {
const task: PendingTask<T, R> = {
id: allocateTaskId(),
payload,
transfer,
resolve,
reject,
enqueuedAt: performance.now(),
attempts: 0
};
this.#queue.push(task);
this.metrics.maxQueueDepth = Math.max(this.metrics.maxQueueDepth, this.#queue.length);
this.#dispatch();
});
}
#dispatch(): void {
while (this.#idle.length > 0 && this.#queue.length > 0) {
const worker = this.#idle.pop()!; // LIFO: reuse the hottest isolate
const task = this.#queue.shift()!; // FIFO: oldest task first
task.attempts++;
this.#busy.set(worker, task);
this.#pending.set(task.id, task);
try {
worker.postMessage({ id: task.id, type: 'RUN', payload: task.payload }, task.transfer);
this.metrics.dispatched++;
} catch (err) {
// DataCloneError: the payload is not structured-cloneable. Throwing here
// would strand the task in #pending and leave the worker marked busy.
this.#pending.delete(task.id);
this.#busy.delete(worker);
this.#idle.push(worker);
this.metrics.failed++;
task.reject(err instanceof Error ? err : new Error(String(err)));
}
}
}
#onReply(worker: Worker, data: TaskResponse<R>): void {
const task = this.#pending.get(data.id);
this.#busy.delete(worker);
this.#idle.push(worker);
if (task) {
this.#pending.delete(data.id);
this.metrics.latencies.push(performance.now() - task.enqueuedAt);
if (data.status === 'OK') {
this.metrics.completed++;
task.resolve(data.result);
} else {
this.metrics.failed++;
const err = new Error(data.message);
err.name = data.name;
if (data.stack) err.stack = data.stack;
task.reject(err);
}
}
this.#dispatch(); // keep the cores fed in the same tick
}
#onFatal(worker: Worker, event: ErrorEvent): void { /* Step 6 */ }
get size(): number { return this.#size; }
get queueDepth(): number { return this.#queue.length; }
get inFlight(): number { return this.#busy.size; }
}
Two ordering choices in #dispatch() are deliberate. Workers come off the idle list LIFO (pop), because the most recently used isolate has the warmest JIT state and the most relevant data still in cache. Tasks come off the queue FIFO (shift), because reordering user-visible work by recency produces unbounded wait times for the oldest request. A stricter version of that fairness argument produces the priority queue in Step 5.
This pool posts a single task to a worker and waits. You could pipeline — post several and let the worker's event loop queue them — which hides the dispatch hop on very short tasks. The cost is that you lose the ability to reassign or cancel queued work, and a crash then destroys N tasks instead of one. Pipeline only when tasks are sub-millisecond and uniformly cheap; a build of this variant is compared in Implementing a Simple Worker Pool in Vanilla JS.
Step 5 — Order work with a priority queue
Once the pool is saturated, ordering is the product decision. A thumbnail for the tile the user is looking at must not sit behind 30 pre-fetch jobs. Swap the FIFO array for a binary min-heap keyed on priority, with an insertion sequence number as tie-break so equal-priority tasks retain FIFO order and nothing starves.
// priority-queue.ts
export enum Priority { Interactive = 0, Default = 1, Background = 2 }
interface HeapEntry<T> { item: T; priority: Priority; seq: number; deadline?: number }
export class PriorityQueue<T> {
#heap: HeapEntry<T>[] = [];
#seq = 0;
get length(): number { return this.#heap.length; }
push(item: T, priority: Priority = Priority.Default, deadline?: number): void {
this.#heap.push({ item, priority, seq: this.#seq++, deadline });
this.#siftUp(this.#heap.length - 1);
}
/** Returns the most urgent non-expired entry, discarding expired ones. */
pop(now: number = performance.now()): T | undefined {
while (this.#heap.length > 0) {
const top = this.#removeRoot()!;
if (top.deadline === undefined || now <= top.deadline) return top.item;
// Expired: drop and keep looking rather than returning undefined,
// which would stall the dispatch loop with work still queued.
}
return undefined;
}
#removeRoot(): HeapEntry<T> | undefined {
const root = this.#heap[0];
const last = this.#heap.pop();
if (this.#heap.length > 0 && last) {
this.#heap[0] = last;
this.#siftDown(0);
}
return root;
}
// Lower priority number wins; equal priority falls back to insertion order.
#before(a: HeapEntry<T>, b: HeapEntry<T>): boolean {
return a.priority !== b.priority ? a.priority < b.priority : a.seq < b.seq;
}
#siftUp(i: number): void {
while (i > 0) {
const parent = (i - 1) >> 1;
if (!this.#before(this.#heap[i], this.#heap[parent])) break;
[this.#heap[i], this.#heap[parent]] = [this.#heap[parent], this.#heap[i]];
i = parent;
}
}
#siftDown(i: number): void {
const n = this.#heap.length;
for (;;) {
const left = 2 * i + 1;
const right = left + 1;
let best = i;
if (left < n && this.#before(this.#heap[left], this.#heap[best])) best = left;
if (right < n && this.#before(this.#heap[right], this.#heap[best])) best = right;
if (best === i) return;
[this.#heap[i], this.#heap[best]] = [this.#heap[best], this.#heap[i]];
i = best;
}
}
}
Dropping this into the pool is a two-line change: #queue becomes a PriorityQueue<PendingTask<T, R>>, run() takes a priority argument, and #dispatch() calls this.#queue.pop() instead of shift(). Heap operations are O(log n) — at a queue depth of 1,000 that is ten comparisons, far below the cost of the postMessage that follows.
t2 drops behind every default task (dashed). Inside each band the seq tie-break preserves arrival order — 1, 3, 7 for the default band and 2, 5, 8 for the background band — which is what stops a busy priority level from starving the one below it.If interactive work arrives faster than the pool drains it, background tasks never run. Two mitigations: age the priority (subtract one level for every 500 ms a task has waited) or reserve one worker that only pulls from the background band. Both cost a little throughput on the urgent path and buy a bounded worst case — pick deliberately, and measure the p95 for each band separately rather than for the pool as a whole.
Step 6 — Recover from crashes and drain on teardown
A pool that loses a worker and does not replace it degrades silently until it is a queue with no consumers. Complete the class with a fatal-error path and an explicit drain protocol.
// worker-pool.ts — continuation of `class WorkerPool` from Step 4
const MAX_RESPAWNS_PER_MINUTE = 5;
const MAX_ATTEMPTS = 2;
export class WorkerPool<T = unknown, R = unknown> {
// ...fields and dispatch loop from Step 4
#respawnTimestamps: number[] = [];
#onFatal(worker: Worker, event: ErrorEvent): void {
const task = this.#busy.get(worker);
this.#busy.delete(worker);
// The idle list may also hold it if the error fired outside a task.
const idx = this.#idle.indexOf(worker);
if (idx !== -1) this.#idle.splice(idx, 1);
worker.terminate(); // an errored isolate is not trustworthy for reuse
if (task) {
this.#pending.delete(task.id);
if (task.attempts < MAX_ATTEMPTS) {
this.#queue.unshift(task); // one retry — the crash may be a bad isolate
} else {
this.metrics.failed++;
task.reject(new Error(`Worker crashed twice on task ${task.id}: ${event.message}`));
}
}
// Rate-limit respawns so a script that throws at module evaluation
// cannot become an unbounded construct/crash loop.
const now = Date.now();
this.#respawnTimestamps = this.#respawnTimestamps.filter((t) => now - t < 60_000);
if (this.#respawnTimestamps.length < MAX_RESPAWNS_PER_MINUTE) {
this.#respawnTimestamps.push(now);
this.metrics.respawned++;
this.#idle.push(this.#spawn());
} else if (this.#idle.length === 0 && this.#busy.size === 0) {
this.#failAll(new Error('Worker pool disabled: respawn rate limit exceeded'));
}
this.#dispatch();
}
/** Resolves once queued and in-flight work settles, then terminates. */
async drain(timeoutMs = 5_000): Promise<void> {
this.#destroyed = true; // reject new submissions immediately
const deadline = Date.now() + timeoutMs;
while ((this.#queue.length > 0 || this.#busy.size > 0) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 16));
}
this.destroy();
}
/** Immediate shutdown: in-flight tasks reject, workers are terminated. */
destroy(): void {
this.#destroyed = true;
this.#failAll(new Error('Pool destroyed'));
for (const worker of [...this.#idle, ...this.#busy.keys()]) worker.terminate();
this.#idle.length = 0;
this.#busy.clear();
}
#failAll(reason: Error): void {
for (const task of this.#pending.values()) task.reject(reason);
this.#pending.clear();
this.#queue = [];
}
}
Call drain() on route change in a single-page app and destroy() in a pagehide handler. Worker.terminate() stops the thread at an arbitrary instruction and runs no cleanup inside it, so anything the worker owns — an open IndexedDB transaction, a partially written OPFS file — must be committed before you terminate. Handling Worker Termination Gracefully in SPAs works through the shutdown handshake that makes this safe.
Clone, Transfer or Share: Moving Data Through the Pool
Dispatch cost is dominated by the payload, not by the pool. Every postMessage runs the structured-clone algorithm unless you opt out, and clone cost scales with the byte count and the object-graph shape — deep graphs of small objects are far worse per byte than one flat typed array. The pool’s run(payload, transfer) signature exists so each caller picks the right strategy. Message Passing Strategies covers the mechanics in full; what follows is the decision as it applies to a pool.
Structured clone — the default. Correct for plain objects, Map, Set, Date, RegExp and typed arrays; costs roughly 1 ms per MB for flat binary data and considerably more for object graphs. Below ~100 KB the difference from a transfer is inside the noise, so prefer clone for its simplicity: the caller keeps its data and nothing is detached.
Transfer — move ownership of an ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas or ReadableStream with a transfer list. The operation is O(1): the buffer is detached from the sender and re-attached in the worker without copying a byte. This is the right default for anything image- or matrix-shaped in a pool, where the same buffers cycle repeatedly.
// Zero-copy dispatch through the pool. Threshold is a heuristic:
// below it, clone cost is smaller than the bookkeeping of ownership.
const TRANSFER_THRESHOLD_BYTES = 100_000;
async function runFrame(pool: WorkerPool<Frame, ArrayBuffer>, pixels: ArrayBuffer) {
const useTransfer = pixels.byteLength >= TRANSFER_THRESHOLD_BYTES;
const result = await pool.run(
{ pixels, width: 1920, height: 1080 },
useTransfer ? [pixels] : []
);
// After a transfer, pixels.byteLength === 0 on this thread — the buffer is
// detached. Any later read throws TypeError, so re-bind to what came back.
return new Uint8ClampedArray(result);
}
The worker must transfer the buffer back, or the pool has quietly donated one allocation per task to the worker’s heap — a leak that only shows up as steadily climbing memory across a long session. Transferable Objects & Zero-Copy covers the full ownership model, and Identifying Memory Leaks in Workers shows how to catch the one-way case in a heap snapshot.
Share — a SharedArrayBuffer maps the same physical pages into every worker, so there is no send at all and no ownership to track. In a pool this is the right answer for exactly one shape: several workers reading or writing disjoint regions of one large dataset concurrently, such as four workers each processing a quarter of a 200 MB point cloud. It requires a cross-origin-isolated document — both Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, verified at runtime with self.crossOriginIsolated === true — and every cross-thread read or write must go through Atomics to be sequentially consistent. SharedArrayBuffer & Atomics covers the memory model and the lock-free patterns.
run(payload, transfer) takes the transfer list from the caller. Clone below the ~100 KB threshold, transfer above it, and reach for SharedArrayBuffer only when several workers must touch the same bytes at once and the document is already cross-origin isolated.Enabling require-corp makes every cross-origin subresource — analytics scripts, fonts, embedded video, third-party iframes — fail to load unless it serves Cross-Origin-Resource-Policy or is fetched with crossorigin. Adopting SharedArrayBuffer for a pool is therefore a page-wide commitment, not a local optimisation. Feature-detect with self.crossOriginIsolated and keep a transfer-based path for documents that are not isolated.
Verification & Measurement
A pool that is not instrumented is a guess. Three measurements tell you whether it is sized correctly and whether the payload strategy is right.
1. End-to-end latency, stamped at enqueue. The metrics.latencies array above records queue wait plus execution — the interval the user experiences. Report the p95 alongside the mean: a mean of 95 ms with a p95 of 850 ms means the pool is fine at rest and saturated under load, which is a sizing problem, whereas both numbers rising together is a payload or algorithm problem.
export function summarise(latencies: number[]) {
if (latencies.length === 0) return null;
const sorted = [...latencies].sort((a, b) => a - b);
const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
return {
n: sorted.length,
meanMs: +(sorted.reduce((s, v) => s + v, 0) / sorted.length).toFixed(2),
p50Ms: +at(0.5).toFixed(2),
p95Ms: +at(0.95).toFixed(2),
maxMs: +sorted[sorted.length - 1].toFixed(2)
};
}
2. The clone/execute split. The worker already returns workerMs (pure execution). Subtracting it from the end-to-end latency and from the queue wait leaves the message overhead. If overhead exceeds execution, the pool is not your problem — the payload is, and postMessage Bottleneck Analysis has the taxonomy for cutting it.
const t0 = performance.now();
const result = await pool.run(payload, transfer);
const roundTripMs = performance.now() - t0;
// roundTripMs - queueWaitMs - workerMs ≈ structured-clone + dispatch overhead
3. The Performance panel, per thread. Record a profile while the pool runs a representative batch. Each worker gets its own lane; the two shapes worth recognising are lanes with visible gaps between tasks (the pool is starved — the queue is empty, so the pool is not the bottleneck) and lanes that are solid while the queue stays deep (the pool is saturated — add a worker or make the task cheaper). Long yellow blocks on the main thread labelled with your worker’s script name are structured-clone serialisation, not worker execution. Chrome DevTools Worker Debugging walks through reading these lanes.
Use performance.mark/measure inside the worker so the timings appear in the recording rather than only in your console:
// task.worker.js
performance.mark('task:start');
const result = await runTask(payload);
performance.mark('task:end');
performance.measure(`task:${id}`, 'task:start', 'task:end');
performance.clearMarks('task:start');
performance.clearMarks('task:end');
performance.clearMeasures(`task:${id}`); // measures accumulate; clear them
On a 4-core laptop, a 3-worker pool running 40 tasks of ~90 ms each finishes in roughly 1.2 s versus 3.6 s single-threaded — about 3× rather than the theoretical 4×, because the main thread still resolves promises, the dispatch hop costs ~0.1 ms per task, and the OS is scheduling a browser, not a benchmark. Efficiency below 2× on a 4-core machine usually means the payload is dominating and the fix is the transfer path, not more workers.
Failure Modes & Error Handling
A worker throws synchronously. The exception surfaces as an ErrorEvent on the Worker object with message, filename and lineno. It does not reach window.onerror, and after an uncaught error the isolate’s state is suspect — hence terminate() plus respawn in Step 6 rather than returning the worker to the idle list.
A worker rejects a promise with no handler. An async task that rejects raises unhandledrejection inside the worker and never reaches the main thread at all, so the pool’s task promise hangs forever. Two defences: try/catch around every awaited task (Step 3 already does), plus a global net inside the worker.
// task.worker.js
self.addEventListener('unhandledrejection', (event) => {
event.preventDefault(); // stop the default console-only reporting
self.postMessage({
id: currentTaskId,
status: 'ERR',
name: 'UnhandledRejection',
message: String(event.reason?.message ?? event.reason)
});
});
A task never replies. A worker stuck in an infinite loop cannot be interrupted — there is no preemption and no way to inject a throw. Only terminate() reclaims the thread, so wrap dispatch in a timeout that terminates and respawns, and treat the timeout as a hard failure rather than a retryable one unless you know the task is idempotent.
function withTimeout<R>(promise: Promise<R>, ms: number, onTimeout: () => void): Promise<R> {
return new Promise<R>((resolve, reject) => {
const timer = setTimeout(() => {
onTimeout(); // pool terminates + respawns the stuck worker
reject(new Error(`Task exceeded ${ms} ms`));
}, ms);
promise.then(resolve, reject).finally(() => clearTimeout(timer));
});
}
The payload cannot be cloned. Functions, DOM nodes, class instances with methods and anything holding a closure throw DataCloneError at the postMessage call — synchronously, on the main thread. Because the throw happens inside #dispatch(), an unguarded pool leaves the task in #pending forever and the worker marked busy. Wrap the post in try/catch, reject the task and return the worker to the idle list.
The reply cannot be deserialised. This fires messageerror, not error — a distinct event that most pool implementations forget to listen for, and the reason Step 4 registers both.
The error loses its identity in transit. Error objects are cloneable in modern engines but arrive as plain Error with the subclass and any custom fields stripped, and stack is unreliable across engines. Flatten errors into { name, message, stack, code } on the worker side and rebuild them on the main thread, as Step 3 does. Structured Error Serialization Across Threads covers the full contract, and Error Handling & Crash Recovery covers the recovery policies around it.
A poison task crashes every worker it touches. Without an attempt cap, one malformed input can take down the whole pool in sequence as the task is retried onto each fresh isolate. MAX_ATTEMPTS = 2 plus the respawn rate limit in Step 6 bounds the blast radius to two workers and surfaces the real error to the caller.
#onFatal. Note the two independent caps: MAX_ATTEMPTS bounds how often a single poison task is retried, and the respawn rate limit bounds how fast a script that throws at module evaluation can be reconstructed.Browser Compatibility
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
Worker constructor |
4 | 3.5 | 4 | 12 |
navigator.hardwareConcurrency |
37 | 48 | 10.1 | 15 |
navigator.deviceMemory |
63 | — | — | 79 |
Module workers ({ type: 'module' }) |
80 | 114 | 15 | 80 |
Transfer list in postMessage |
17 | 18 | 5.1 | 12 |
requestIdleCallback |
47 | 55 | 16 | 79 |
messageerror event on Worker |
60 | 57 | 12.1 | 79 |
SharedArrayBuffer (COOP/COEP required) |
92 | 79 | 15.2 | 92 |
performance.measure in workers |
45 | 41 | 11 | 79 |
Two entries set the practical floor. Module workers arrived in Firefox only at 114 (June 2023), so a pool that must run on older Firefox needs a classic-worker build with importScripts() — most bundlers emit both from one source. And requestIdleCallback is absent in Safari before 16, which is why Step 2 ships the setTimeout fallback rather than feature-gating the warm-up away entirely.
Where to Go From Here
The class above is a fixed-size pool, which is the right default: it allocates once, has a predictable memory ceiling and cannot thrash. When arrival rates are spiky enough that a fixed size is either idle or overwhelmed, Dynamic vs Fixed-Size Worker Pools covers the growth and shrink policies with measured numbers. For a dependency-free version stripped to its essentials — useful for reading the algorithm end to end before adding priorities and telemetry — see Implementing a Simple Worker Pool in Vanilla JS.
Going Further
A queue is also where cancellation belongs. Dropping a superseded job before it is dispatched costs nothing and saves the whole computation, which makes it far more valuable than the terminate-and-rebuild most pools reach for first. Cancelling Worker Tasks with AbortSignal covers all three states a job can be cancelled in, and how a running task can be made to notice.