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.

The dispatch loop of a worker pool Queued tasks enter a dispatcher, which pops the least recently used idle worker and posts the task to it. Worker 1 is busy; workers 0, 2 and 3 are idle. When a worker replies, the pool pushes it back onto the idle stack and immediately dispatches the next queued task, closing the loop. Task queue task A · priority 0 task B · priority 1 task C · priority 1 task D · priority 2 task E · priority 2 FIFO now, heap in Step 5 Dispatcher idle.pop() Worker 0 idle Worker 1 busy · task in flight Worker 2 idle Worker 3 idle Reply handled idle.push(worker) the same tick: push the worker back, dispatch the next task
The dispatch loop. The dispatcher takes the oldest queued task and the most recently used idle worker (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.

Wall-clock cost of three worker strategies for the same 40-task batch Timeline (a): forty Worker constructions occupy the main thread for 300 to 600 milliseconds before any task runs, and forty live isolates hold 80 to 240 megabytes. Timeline (b): a single long-lived worker runs the forty 90-millisecond tasks strictly serially for 3.6 seconds while three cores stay idle. Timeline (c): a pre-warmed pool of three workers pays construction once during idle time and finishes the batch in roughly 1.2 seconds across three parallel lanes. Same batch: 40 images, ~90 ms of computation each, 4-core laptop (a) new Worker() per task — fast enough, and it janks the main thread main thread 40 isolates 40 × new Worker() — 300–600 ms of jank before any task runs 80–240 MB of live heap ≈1.5 s (b) one long-lived worker — no jank at all, and unusably slow main thread worker 0 cores 1–3 construction paid once — main thread free idle for the whole run — three cores wasted 3.6 s (c) pre-warmed pool of 3 — construction paid once, off the critical path main thread worker 0 worker 1 worker 2 3 × spawn in requestIdleCallback — paid once, off the critical path three lanes in parallel — ≈3× the single-worker throughput ≈1.2 s total 0 1 s 2 s 3 s wall clock
The same 40-image batch under three strategies. Worker-per-task is not slow — it is janky: 300–600 ms of construction lands on the main thread before the first task runs, and 40 live isolates carry 80–240 MB. One long-lived worker removes the jank and serialises the work into 3.6 s with three cores idle. The pre-warmed pool pays construction once during idle time and finishes in roughly 1.2 s.
Where the milliseconds go

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.hardwareConcurrency support (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 reports 2.
  • 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 import inside the worker; otherwise ship a classic worker and use importScripts().
  • COOP/COEP headersCross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corponly if the pool will share memory via SharedArrayBuffer. Without both, the constructor is undefined in 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));
}
Trade-off: the sizing ceiling

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)
});
Trade-off: warm now or fast later

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
    });
  }
});
Trade-off: ids cost a lookup, instances cost correctness

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.

Trade-off: one message per worker

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.

Arrival order versus min-heap dispatch order The left column lists eight tasks in the order they were enqueued, each tagged interactive, default or background. The right column lists the order the binary min-heap pops them: the two interactive tasks first, then the three default tasks, then the three background tasks. Within each band the insertion sequence number keeps the original arrival order, so no task starves. The same eight tasks, queued and then dispatched arrival order (enqueue) dispatch order (heap pop) t1 default t2 background t3 default t4 interactive t5 background t6 interactive t7 default t8 background t4 seq 4 t6 seq 6 t1 seq 1 t3 seq 3 t7 seq 7 t2 seq 2 t5 seq 5 t8 seq 8 interactive default background priority first, then seq equal priorities keep arrival order — the insertion sequence number breaks every tie, so nothing starves
Eight tasks, enqueued on the left and popped on the right. The two interactive tasks jump the queue (solid arrows) while 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.
Trade-off: priorities can starve the tail

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.

Choosing clone, transfer or shared memory for a pooled task A four-row matrix. Structured clone costs about 1 millisecond per megabyte, keeps the sender copy, needs no headers and suits payloads under 100 kilobytes. A transfer list is O(1), detaches the sender buffer, needs no headers and suits ArrayBuffers, ImageBitmaps and OffscreenCanvas over roughly 100 kilobytes. SharedArrayBuffer has no send cost, shares live bytes with every thread, requires COOP same-origin and COEP require-corp, and suits several workers reading disjoint slices of one large dataset. Every dispatch picks one of these three — per payload, not per pool structured clone transfer list SharedArrayBuffer dispatch cost ≈1 ms per MB of flat bytes, far worse for object graphs O(1) — ownership moves, not a byte is copied no send at all — the pages map into every worker sender's data kept — the caller still owns its copy afterwards detached — byteLength is 0, later reads throw shared — every thread sees the same live bytes headers needed none none COOP: same-origin plus COEP: require-corp the pool shape it fits payloads under ~100 KB: objects, Map, Set, Date ArrayBuffer, ImageBitmap, OffscreenCanvas > ~100 KB workers reading disjoint slices of one dataset Cross-origin isolation is page-wide: it changes how every third-party subresource loads.
The choice is per payload, not per pool — which is why 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.
Cross-origin isolation is all-or-nothing

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
Reading pool health from the Performance panel Top: a two-worker pool. Both worker lanes are solid end to end and the queue-depth track climbs and stays high, so a mean latency of 95 milliseconds hides a p95 of 850 milliseconds that is almost all queue wait. Bottom: the same batch on three workers. The lanes show visible gaps where the queue ran dry, the queue-depth track stays flat near zero, and the p95 sits within about 1.5 times the mean. (a) 2 workers — saturated — mean 95 ms, p95 850 ms: a 9× gap main thread worker 0 worker 1 dispatch and resolve hops — the main thread is free queue depth the queue never drains — the p95 is queue wait, not execution (b) 3 workers — right-sized — p95 within ~1.5× of the mean main thread worker 0 worker 1 worker 2 same hops, spread across three lanes gaps = queue empty queue depth flat — tasks dispatch on arrival, so latency is execution Solid lanes plus a deep queue: add a worker or shrink the task. Gappy lanes plus a flat queue: the pool is not the bottleneck.
The two shapes worth recognising in a Performance recording. Solid worker lanes over a queue that never drains mean the pool is saturated — the p95 is queue wait, and the fix is another worker or a cheaper task. Lanes with visible gaps over a flat queue mean the pool is starved of work, so the bottleneck is somewhere else.
A number to expect

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.

The lifecycle of a single pooled worker A worker moves from idle to busy when the dispatcher posts a task, and back to idle when the reply resolves or rejects the promise. An error event, a messageerror event or a task timeout moves it to terminated instead: the in-flight task is requeued when it has been attempted fewer than twice and rejected otherwise. A terminated worker is never reused; the pool spawns a replacement, capped at five respawns per minute, and the replacement enters the idle stack. Once that cap is exceeded the pool is disabled and every queued task rejects. idle in the idle stack busy one task in flight terminated terminate(), never reused respawned new Worker(url) pool disabled queued tasks reject dispatch: postMessage reply: resolve or reject error / messageerror or task timeout attempts < 2: requeue otherwise reject spawn a replacement ≤ 5 respawns / minute capacity restored > 5/min An errored isolate never returns to the idle stack — capacity comes back only from a fresh Worker
One pooled worker’s lifecycle. The happy path is the pair of horizontal edges at the top; everything below them is #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.

Frequently Asked Questions

What is the optimal worker pool size for CPU-bound tasks?
Start at navigator.hardwareConcurrency - 1, clamped to a sane range such as 2–8. hardwareConcurrency reports logical cores (hyper-threads), so on a 4-core/8-thread laptop it returns 8 and eight compute-bound workers will contend for four physical execution units. Reserving one thread leaves headroom for the main thread’s rendering and event handling. Add at most one overflow worker during sustained bursts and drain it after an idle timeout.
How should I handle a worker crash inside a pool?
Treat the error event as fatal for that instance: reject the in-flight task’s promise, call terminate(), drop the worker from both the idle and busy sets, and spawn a replacement so pool capacity stays constant. Keep the script URL on the pool object — Worker instances do not expose a .url property, so without it you cannot respawn. Cap replacements (for example five per minute) so a script that throws at module-evaluation time cannot become a respawn loop.
When should I choose a dynamic pool size over a fixed one?
Fixed pools are simpler, allocate their memory once, and cannot grow unbounded — prefer them for predictable batch workloads. Dynamic pools pay off when task volume is spiky: grow when queue depth stays above a threshold for several dispatch cycles, shrink after an idle timeout. See Dynamic vs Fixed-Size Worker Pools for the comparison with measured throughput.
How do I measure average task latency across a pool?
Stamp performance.now() when a task is enqueued, not when it is dispatched, and subtract it from the completion timestamp inside the worker’s message handler. Queue wait plus execution is what the user actually feels. Track the p95 as well as the mean — a healthy pool with a saturated queue shows a low mean and a p95 several times higher, which is the signal to add a worker or shrink the payload.

See also