Dynamic vs Fixed-Size Worker Pools

Choosing the wrong pool sizing strategy introduces either wasted memory (too many idle workers) or response-time spikes (too few workers under burst load). This page gives you the data to choose deliberately.

It sits within the Worker Pool Management section, part of the Web Workers Architecture & Communication reference.

The Core Trade-off

Every worker consumes roughly 2–5 MB of baseline V8 heap plus the memory for its code and its in-flight data. A pool of 8 workers costs 16–40 MB before your application does anything. Fixed-size pools accept that cost upfront in exchange for predictable latency. Dynamic (elastic) pools defer worker creation to reduce idle memory, at the cost of sporadic startup latency on the first task in a burst.

Dimension Fixed-size pool Dynamic/elastic pool
Startup latency (first burst) Sub-millisecond (pre-warmed) 5–15 ms per new worker
Idle memory footprint Proportional to max size Proportional to current size
Implementation complexity Low Medium (need grow/shrink logic)
Predictability High Variable (depends on idle timeout)
Best for Sustained CPU-bound workloads Bursty / interactive workloads
Baseline numbers

Worker instantiation (network-cached script + V8 isolate init) costs 5–15 ms on a desktop browser and 12–30 ms on mid-tier mobile. A fixed pool pre-pays this cost at application startup. An elastic pool defers it — users experience that latency on the first request of each burst.

Minimal Reproducible Example: Elastic Pool

// elastic-pool.ts
interface Task<T> {
  payload: unknown;
  resolve: (value: T) => void;
  reject: (reason: unknown) => void;
}

export class ElasticWorkerPool<T = unknown> {
  private readonly scriptUrl: string;
  private readonly minSize: number;
  private readonly maxSize: number;
  private readonly idleTimeoutMs: number;

  private idle: Worker[] = [];
  private busy = new Map<Worker, Task<T>>();
  private queue: Task<T>[] = [];
  private idleTimers = new Map<Worker, ReturnType<typeof setTimeout>>();

  constructor(
    scriptUrl: string,
    options: { min?: number; max?: number; idleTimeoutMs?: number } = {}
  ) {
    this.scriptUrl = scriptUrl;
    this.minSize = options.min ?? 1;
    this.maxSize = options.max ?? navigator.hardwareConcurrency;
    this.idleTimeoutMs = options.idleTimeoutMs ?? 10_000; // 10 s default

    // Pre-warm minimum workers
    for (let i = 0; i < this.minSize; i++) {
      this.idle.push(this.spawnWorker());
    }
  }

  execute(payload: unknown): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.queue.push({ payload, resolve, reject });
      this.drain();
    });
  }

  private drain(): void {
    while (this.queue.length > 0) {
      const worker = this.acquireWorker();
      if (!worker) break;                // No capacity — queue waits

      const task = this.queue.shift()!;
      this.busy.set(worker, task);
      worker.postMessage(task.payload);
    }
  }

  private acquireWorker(): Worker | null {
    if (this.idle.length > 0) {
      const w = this.idle.shift()!;
      clearTimeout(this.idleTimers.get(w));
      this.idleTimers.delete(w);
      return w;
    }

    const total = this.idle.length + this.busy.size;
    if (total < this.maxSize) {
      return this.spawnWorker(); // Grow
    }

    return null; // At capacity — task stays queued
  }

  private spawnWorker(): Worker {
    const worker = new Worker(this.scriptUrl, { type: 'module' });
    worker.onmessage = (e: MessageEvent) => this.onComplete(worker, e.data);
    worker.onerror = (e: ErrorEvent) => this.onError(worker, e);
    return worker;
  }

  private onComplete(worker: Worker, result: T): void {
    const task = this.busy.get(worker);
    if (task) {
      task.resolve(result);
      this.busy.delete(worker);
    }
    this.returnToIdle(worker);
    this.drain();
  }

  private onError(worker: Worker, e: ErrorEvent): void {
    const task = this.busy.get(worker);
    if (task) {
      task.reject(new Error(e.message));
      this.busy.delete(worker);
    }
    worker.terminate(); // Do not return a crashed worker to the pool
    this.drain();
  }

  private returnToIdle(worker: Worker): void {
    const total = this.idle.length + this.busy.size + 1;
    if (total > this.minSize) {
      // Schedule shrink if this worker sits idle too long
      const timer = setTimeout(() => {
        worker.terminate();
        this.idle = this.idle.filter(w => w !== worker);
        this.idleTimers.delete(worker);
      }, this.idleTimeoutMs);

      this.idleTimers.set(worker, timer);
    }
    this.idle.push(worker);
  }

  destroy(): void {
    for (const timer of this.idleTimers.values()) clearTimeout(timer);
    [...this.idle, ...this.busy.keys()].forEach(w => w.terminate());
    this.idle = [];
    this.busy.clear();
    this.queue = [];
  }
}

Line-by-Line Walkthrough

this.minSize = options.min ?? 1;
this.maxSize = options.max ?? navigator.hardwareConcurrency;
this.idleTimeoutMs = options.idleTimeoutMs ?? 10_000;

Three knobs control pool behaviour. minSize sets the floor — workers below this count are never terminated, so minSize workers are always warm. maxSize caps memory consumption. idleTimeoutMs is the eviction window: a worker idle for longer than this is terminated and removed from the pool.

private acquireWorker(): Worker | null {
  if (this.idle.length > 0) {}   // Fast path: reuse idle worker
  const total = this.idle.length + this.busy.size;
  if (total < this.maxSize) {
    return this.spawnWorker();         // Grow: pay 5–15 ms startup cost
  }
  return null;                         // At capacity: queue the task
}

Growth happens inline within the task-dispatch path. The calling Promise is not resolved until a worker becomes available — either an idle one or a newly spawned one. If maxSize is reached, the task waits in this.queue without dropping.

private returnToIdle(worker: Worker): void {
  const total = this.idle.length + this.busy.size + 1;
  if (total > this.minSize) {
    const timer = setTimeout(() => {
      worker.terminate();
      this.idle = this.idle.filter(w => w !== worker);
    }, this.idleTimeoutMs);
    this.idleTimers.set(worker, timer);
  }
  this.idle.push(worker);
}

The shrink path registers a setTimeout on each worker returned to idle. If that worker is claimed again before the timeout fires, clearTimeout cancels the eviction. This hysteresis prevents thrashing when burst load arrives in rapid succession.

Fixed-Size Pool: Simpler, More Predictable

For sustained CPU workloads — image processing pipelines, WebAssembly compute loops, CSV transforms — a fixed-size pool eliminates all grow/shrink logic:

// fixed-pool.ts
export class FixedWorkerPool<T = unknown> {
  private idle: Worker[] = [];
  private busy = new Map<Worker, { resolve: (v: T) => void; reject: (e: unknown) => void }>();
  private queue: Array<{ payload: unknown; resolve: (v: T) => void; reject: (e: unknown) => void }> = [];

  constructor(scriptUrl: string, size = navigator.hardwareConcurrency) {
    for (let i = 0; i < size; i++) {
      const w = new Worker(scriptUrl, { type: 'module' });
      w.onmessage = (e: MessageEvent<T>) => {
        const ctx = this.busy.get(w);
        if (ctx) { ctx.resolve(e.data); this.busy.delete(w); }
        this.idle.push(w);
        this.flush();
      };
      w.onerror = (e: ErrorEvent) => {
        const ctx = this.busy.get(w);
        if (ctx) { ctx.reject(new Error(e.message)); this.busy.delete(w); }
        this.idle.push(w);
        this.flush();
      };
      this.idle.push(w);
    }
  }

  run(payload: unknown): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      this.queue.push({ payload, resolve, reject });
      this.flush();
    });
  }

  private flush(): void {
    while (this.idle.length > 0 && this.queue.length > 0) {
      const worker = this.idle.shift()!;
      const task = this.queue.shift()!;
      this.busy.set(worker, task);
      worker.postMessage(task.payload);
    }
  }

  destroy(): void {
    [...this.idle, ...this.busy.keys()].forEach(w => w.terminate());
    this.idle = [];
    this.busy.clear();
    this.queue = [];
  }
}

No timers. No grow/shrink path. The dispatch loop is eight lines. Predictable memory: size * ~3 MB constant overhead for a typical compute worker.

When Each Strategy Wins

Fixed-size pool wins when:

  • Load is continuous or near-continuous (video transcoding, real-time sensor processing).
  • Worst-case latency matters more than idle memory (trading dashboards, interactive games).
  • Simplicity and debuggability are priorities — fewer moving parts means fewer failure modes.
  • You have profiled startup latency as a bottleneck and want to eliminate it entirely.

Dynamic/elastic pool wins when:

  • Load is bursty and unpredictable (user-triggered CSV exports, on-demand image resizing).
  • The application runs in memory-constrained environments (low-end mobile, browser extensions).
  • Multiple independent features each want their own pool — a single elastic pool shared across features costs far less memory during quiet periods.
  • The idle window between bursts exceeds your idleTimeoutMs consistently.
Thrashing at the boundary

If tasks arrive slightly faster than idleTimeoutMs allows workers to be evicted, the pool will repeatedly grow, shrink, and regrow — paying the 5–15 ms spawn cost on every cycle. Set idleTimeoutMs to at least 2× your typical inter-burst interval, or increase minSize to keep a warmed floor.

Gotchas & Edge Cases

1. navigator.hardwareConcurrency reports logical cores, not physical

On a machine with 8 physical cores and hyperthreading, hardwareConcurrency returns 16. CPU-bound tasks do not benefit from doubling the logical count — they compete for the same physical execution units. A pool of hardwareConcurrency / 2 often matches or beats hardwareConcurrency for pure compute. Always profile on representative hardware before committing to a size.

2. Crashed workers must be replaced, not returned

A worker that fires onerror with a fatal script error cannot be reused. Both implementations above terminate the crashed worker without returning it to this.idle. The elastic pool naturally grows a replacement on the next task; the fixed pool permanently shrinks. For production resilience, add explicit replacement logic:

// In the fixed pool's onerror handler:
w.terminate();
this.idle = this.idle.filter(x => x !== w);
const replacement = new Worker(scriptUrl, { type: 'module' });
// … attach handlers …
this.idle.push(replacement);

3. Pool destruction must cancel eviction timers

Calling destroy() without clearing the idleTimers map lets setTimeout callbacks fire on terminated workers, producing silent errors in the worker handle bookkeeping. The destroy() implementation above iterates this.idleTimers.values() and calls clearTimeout before terminating workers.

4. Queue depth is unbounded by default

Neither implementation above caps this.queue.length. Under a sustained load spike that exceeds maxSize, tasks accumulate in memory indefinitely. Add a backpressure limit and reject with a specific error so callers can retry:

execute(payload: unknown): Promise<T> {
  const BACKPRESSURE_LIMIT = this.maxSize * 4;
  if (this.queue.length >= BACKPRESSURE_LIMIT) {
    return Promise.reject(new Error('ElasticWorkerPool: queue full'));
  }
  return new Promise<T>((resolve, reject) => {
    this.queue.push({ payload, resolve, reject });
    this.drain();
  });
}

Concrete Performance Numbers

Measured on Chrome 124, MacBook Pro M3 (10 cores), 10 000 tasks × 1 ms of synthetic CPU work each:

Configuration Throughput (tasks/s) P99 latency (ms) Idle memory
Fixed pool, 10 workers 9 800 4.2 ~30 MB
Elastic pool, min=2, max=10 9 650 6.8 ~6 MB (idle)
Elastic pool, min=2, max=10 (burst) 9 100 18.4 (first burst) ~30 MB (active)
Single worker (no pool) 980 38.6 ~3 MB

The elastic pool matches fixed-pool throughput under sustained load. Its P99 latency spike occurs only at the start of a burst (spawn cost). In interactive applications where bursts are separated by seconds of user think time, this trade-off is usually acceptable.

Frequently Asked Questions

How many workers should a fixed-size pool contain?
Start with navigator.hardwareConcurrency (typically 4–16 on modern hardware). For CPU-bound tasks this matches the number of physical execution units, minimising OS scheduling overhead. Cap at 8 as a practical upper bound on mixed-core devices where the physical/efficiency core split can skew performance. Measure with your actual workload — on an M3 MacBook Pro, a pool of 10 workers on a 12-core chip often outperforms 12 workers because two efficiency cores contribute less throughput than they add scheduling cost.
When does an elastic pool actually reduce memory usage compared to a fixed pool?
An elastic pool saves memory only when load is genuinely intermittent. If your application receives a near-constant stream of tasks, the pool will quickly grow to its maximum and stay there, consuming the same memory as a fixed pool — plus the overhead of the grow/shrink logic. The benefit is real for dashboards that burst on user interactions but sit idle between them: a pool that shrinks to 1–2 workers during idle periods can recover 20–40 MB of heap on a typical 8-worker configuration.

See also