Dynamic vs Fixed-Size Worker Pools

Pick the wrong pool sizing strategy and you pay for it either in memory that idle workers hold hostage, or in response-time spikes when a burst arrives and there is no warm thread to serve it.

This decision sits at the centre of Worker Pool Management, part of the Web Workers Architecture & Communication reference, and it comes up the moment a working fixed pool — like the one built in Implementing a Simple Worker Pool in Vanilla JS — meets a workload that is idle most of the time. The two strategies are not better and worse; they trade the same resource in opposite directions, and the arrival pattern of your tasks decides which trade is the right one.

The Core Trade-off

Every worker costs roughly 2–5 MB of baseline V8 heap before your code allocates anything, plus its script and its in-flight data. A pool of 8 therefore starts at 16–40 MB. A fixed-size pool pays that bill upfront and never thinks about it again, buying sub-millisecond dispatch for every task. An elastic pool defers the bill, and pays instead in isolate startup latency on the first task of each burst.

Dimension Fixed-size pool Elastic pool
First-task latency in a burst Sub-millisecond (pre-warmed) 5–15 ms per worker spawned
Idle memory footprint Proportional to maximum size Proportional to current size
Steady-state throughput Maximum Equal, once grown
Implementation complexity Low — one idle list, one queue Medium — grow path, eviction timers, hysteresis
Predictability High Varies with the idle timeout and arrival gaps
Failure mode to watch Idle workers wasting heap Thrashing at the eviction boundary
Best for Sustained CPU-bound workloads Bursty, user-triggered workloads
Baseline numbers

Worker instantiation — script fetch from cache, parse, and V8 isolate boot — costs 5–15 ms on desktop and 12–30 ms on a mid-tier phone. A fixed pool pre-pays this at application startup, ideally inside a requestIdleCallback so it does not compete with first paint. An elastic pool defers it, and a real user feels it on the first click of each burst.

Live worker count over one bursty minute: fixed versus elastic Two aligned timelines spanning sixty seconds, with eight tasks arriving at four seconds and eight more at thirty-four seconds. The fixed pool holds eight workers for the entire minute, so the first task of each burst dispatches in well under a millisecond but roughly 24 to 40 megabytes stay resident even while nothing is happening. The elastic pool sits at its floor of two between bursts, spikes to eight at the head of each burst by spawning six workers at 5 to 15 milliseconds each, holds eight until the ten-second idle timeout expires, then releases workers one at a time back down to the floor, freeing roughly 18 to 30 megabytes. One minute of bursty, user-triggered load: two bursts of eight tasks, ceiling of eight workers Fixed pool, size 8 — pre-warmed at startup, never released 8 4 0 8 isolates resident for the whole 60 s ~24–40 MB held even while nothing is happening first task ≪1 ms first task ≪1 ms arrivals burst 1 burst 2 Elastic pool, min 2 / max 8 — idle timeout 10 s 8 4 0 +6 spawns · 5–15 ms each +6 spawns · 5–15 ms each floor min = 2 — never evicted 0 s 10 20 30 40 50 60 s pool shrinks to the floor — 6 isolates released, ≈18–30 MB freed
The same workload under both strategies. The fixed pool's line is flat: it buys sub-millisecond dispatch at both burst heads by holding all eight isolates for the full minute. The elastic pool tracks demand instead — six spawns at the head of each burst, then staggered per-worker eviction once each has been cold for idleTimeoutMs. The shrink is not a single collapse: each worker's own timer fires when it went cold, so the count walks down 8 → 6 → 5 → 3 → 2 over a couple of seconds.

Minimal Reproducible Example: An Elastic Pool

The smallest complete pool that grows on demand and shrinks when quiet. Three fields drive everything: a floor that is never evicted, a ceiling that bounds memory, and an idle timeout that decides how patient the pool is before releasing a worker.

// 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 the floor so the first task never pays isolate startup.
    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;                // At capacity — the 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));  // Re-use cancels a pending eviction.
      this.idleTimers.delete(w);
      return w;
    }

    const total = this.idle.length + this.busy.size;
    if (total < this.maxSize) {
      return this.spawnWorker();           // Grow: pay 5–15 ms once.
    }

    return null;                           // Ceiling reached — stay queued.
  }

  private spawnWorker(): Worker {
    const worker = new Worker(this.scriptUrl, { type: 'module' });
    worker.onmessage = (e: MessageEvent) => this.onComplete(worker, e.data as T);
    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();  // A crashed worker never goes back in the pool.
    this.drain();        // The next task simply grows a replacement.
  }

  private returnToIdle(worker: Worker): void {
    const total = this.idle.length + this.busy.size + 1;
    if (total > this.minSize) {
      // Above the floor: arm an eviction timer for this specific worker.
      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.idleTimers.clear();
    [...this.idle, ...this.busy.keys()].forEach(w => w.terminate());
    this.idle = [];
    this.busy.clear();
    this.queue = [];
  }
}
The lifecycle of one worker inside ElasticWorkerPool Four states. A worker created by the constructor to fill the floor goes from spawning to idle; a worker grown on demand inside acquireWorker skips idle and goes straight to busy with its first task. From idle, acquireWorker clears the pending eviction timer and moves the worker to busy. When onmessage fires, returnToIdle puts it back in the idle list and re-arms the timer if the pool is above minSize. Two paths lead to termination: the idle timeout elapsing, and onerror, after which the worker is never returned to the pool. Workers at or below minSize never arm a timer, so they stay warm for the session. One worker's lifecycle in ElasticWorkerPool — the states, and the calls that move between them grow path — spawned and dispatched immediately (5–15 ms) SPAWNING isolate boot IDLE eviction timer armed only above minSize BUSY task in flight TERMINATED heap released pre-warm ×minSize (constructor) acquireWorker() eviction timer cleared onmessage → returnToIdle() · timer re-armed idleTimeoutMs elapsed terminate() fatal error onerror → terminate(), never reused Workers at or below minSize never arm the eviction timer — they stay warm for the whole session.
Every behavioural difference from a fixed pool lives on two of these edges. The grow path bypasses IDLE entirely — a worker spawned inside acquireWorker() is handed a task the moment it exists. The eviction edge is the only one driven by a timer rather than a message, and re-acquiring the worker cancels it, which is the hysteresis that stops the pool tearing itself down between two closely spaced clicks.

Step-by-Step Walkthrough

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

Three knobs, and every behavioural difference between this pool and a fixed one comes from them. minSize is the warm floor: workers at or below this count are never evicted, so a floor of 1 already removes spawn latency from the first task of a burst. maxSize is the memory ceiling. idleTimeoutMs is the eviction window — how long a surplus worker may sit doing nothing before the pool releases its heap.

private acquireWorker(): Worker | null {
  if (this.idle.length > 0) { /* fast path: reuse, cancel eviction timer */ }
  const total = this.idle.length + this.busy.size;
  if (total < this.maxSize) {
    return this.spawnWorker();          // Grow inside the dispatch path.
  }
  return null;                          // At capacity: leave it queued.
}

Growth happens inline in the dispatch path rather than in a background monitor, which is what keeps the implementation honest: a task is never dropped and never silently delayed by a polling interval. The returned promise stays pending until either an idle worker frees up or a freshly spawned one accepts the payload. Note the ordering — the idle list is checked first, so a warm worker is always preferred to a new one even when the pool is far below its ceiling.

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.idleTimers.delete(worker);
    }, this.idleTimeoutMs);
    this.idleTimers.set(worker, timer);
  }
  this.idle.push(worker);
}

This is the whole shrink path. Each worker returned above the floor arms its own eviction timer, keyed in idleTimers so it can be cancelled individually. If that worker is claimed again before the timeout fires, clearTimeout in acquireWorker cancels the eviction — the hysteresis that stops the pool tearing itself down between two clicks a second apart. Timers are per-worker rather than a single sweep interval, so eviction is staggered: a pool that grew to 8 for one burst releases workers one at a time as each individually goes cold, instead of collapsing to the floor in a single tick.

One deliberate omission: the pool never inspects the payload. Whether you post a structured-cloned object or hand over ownership of an ArrayBuffer with Transferable Objects & Zero-Copy is the caller’s decision, and it changes the economics — a pool whose per-task cost is dominated by cloning a 10 MB payload will not get faster by adding workers.

The Fixed-Size Baseline

For sustained CPU work — image pipelines, WebAssembly compute loops, CSV transforms — the elastic machinery earns nothing, because the pool is always at its ceiling anyway. Delete it:

// 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 path, no eviction bookkeeping — the dispatch loop is eight lines and the memory profile is a flat line at size × ~3 MB. That flatness is the feature: it is trivially reasoned about in a heap snapshot, and there is no configuration whose misjudgement can cause a latency spike in production.

The two dispatch paths, side by side On the left, the fixed pool: run(payload) enqueues, flush() runs, and a single test asks whether an idle worker is available — yes dispatches with postMessage, no leaves the task queued. On the right, the elastic pool: execute(payload) enqueues, drain() runs, then the same idle-worker test, whose yes branch also has to clear the pending eviction timer. Its no branch falls through to a second test, total less than maxSize, whose yes branch spawns a worker at a cost of 5 to 15 milliseconds and whose no branch leaves the task queued. The extra decision and the extra timer are the whole of the added complexity. Fixed pool — one branch run(payload) flush() idle worker available? yes postMessage() dispatched now no stay queued Elastic pool — three branches execute(payload) drain() idle worker available? yes reuse worker clearTimeout() no total < maxSize? yes spawn worker +5–15 ms no stay queued Pumpkin outlines mark the elastic-only work: one extra decision (grow) and one timer that every reuse must cancel.
Both paths share a queue, a dispatch loop and a postMessage. The elastic pool adds exactly two things — a second test on the fall-through of the idle check, and the clearTimeout that any reuse must perform. That is the entire complexity delta, and it is also the entire surface where a bug can turn into a leaked timer firing at a terminated worker.

Choosing Between Them

A fixed-size pool wins when:

  • Load is continuous or near-continuous — video transcoding, real-time sensor processing, a batch job draining a large queue.
  • Worst-case latency matters more than idle memory: trading dashboards, games, anything where a 15 ms spike at the head of an interaction is visible.
  • Simplicity is worth paying for. Fewer moving parts means fewer failure modes, and no timer can misfire on a worker that was already terminated.
  • You have already profiled spawn latency as a bottleneck and want it gone entirely.

An elastic pool wins when:

  • Load is bursty and user-triggered — CSV exports, on-demand image resizing, a search-as-you-type index rebuild.
  • The application runs where memory is scarce: low-end Android, browser extensions, or an embedded webview sharing a budget with a host app.
  • Several independent features each want a pool. Four fixed pools of 8 is 32 idle isolates; four elastic pools with a floor of 1 is four.
  • The gap between bursts reliably exceeds idleTimeoutMs, so shrinking actually happens rather than being cancelled every time.

Picking min, max and idleTimeoutMs

Instrument first: record performance.now() at every execute() call for a representative session and look at the distribution of the gaps between them. That histogram answers all three settings.

  • maxSizenavigator.hardwareConcurrency - 1, clamped to 2–8, with a fallback of 4 when the property is missing. This is the same ceiling a fixed pool would use; elasticity changes when workers exist, not how many are useful.
  • minSize — the number of tasks that must start instantly. For an interactive surface where one click triggers one job, 1 is enough; if a single interaction fans out into parallel work, set the floor to that fan-out width, commonly 2.
  • idleTimeoutMs — at least twice the median inter-burst gap, and never below about 2 s. A timeout shorter than the gap between two interactions in the same user flow means every interaction pays spawn cost, which is the worst of both strategies: elastic latency with fixed-pool complexity.
Thrashing at the boundary

When tasks arrive just slightly slower than idleTimeoutMs, the pool grows, evicts, and regrows on every cycle — paying the 5–15 ms spawn cost repeatedly while never holding the memory long enough to benefit. Watch for a sawtooth in the worker count. The fix is either a longer idleTimeoutMs or a higher minSize; both convert the sawtooth into a flat warm floor.

Choosing between a fixed and an elastic worker pool Three questions asked in order. First, are task arrivals continuous? If yes, use a fixed pool sized at hardwareConcurrency minus one, clamped to two through eight. If no, is the median gap between bursts longer than two seconds? If not, still use a fixed pool, because an elastic one would thrash at the eviction boundary. If it is, is idle memory constrained, as on mobile, in an extension, or when several pools coexist? If not, use a fixed pool because simplicity beats a small idle-memory saving. Only if it is do you use an elastic pool, with a floor of one or two, a ceiling of hardwareConcurrency minus one, and an idle timeout of twice the median gap. Ask three questions about the arrival pattern, in order — three of the four answers end at a fixed pool Are task arrivals continuous? yes Fixed pool size = hardwareConcurrency − 1, clamped 2–8 no Median gap between bursts > 2 s? no Fixed pool an elastic pool would thrash at the boundary yes Is idle memory constrained? mobile · extension · several pools no Fixed pool simplicity beats a small idle-memory saving yes Elastic pool min 1–2 · max hardwareConcurrency − 1 · idleTimeoutMs = 2 × median gap
The tree is deliberately lopsided. Elasticity has to clear three separate bars before it earns its keep — intermittent arrivals, gaps long enough that eviction actually fires, and a memory budget tight enough to care — and failing any one of them lands you back on the simpler pool.

Gotchas and Edge Cases

1. navigator.hardwareConcurrency reports logical cores, not physical ones

On an 8-core machine with hyper-threading it returns 16, and 16 compute-bound workers will fight over 8 physical execution units while adding 16 isolates’ worth of heap. On hybrid ARM designs it counts efficiency cores that contribute a fraction of a performance core’s throughput. Treat the value as an upper bound to clamp, not a recommendation: Math.min(8, Math.max(2, (navigator.hardwareConcurrency ?? 4) - 1)) is a defensible default for both strategies, and only a measurement on representative hardware can improve on it.

2. A crashed worker must be replaced, not returned

A worker that fires onerror on a fatal script error cannot be trusted with the next task. Both implementations above terminate it without pushing it back onto this.idle — but the consequences differ. The elastic pool self-heals, because the next acquireWorker call sees total < maxSize and grows a replacement. The fixed pool silently and permanently loses capacity, so it needs explicit respawn logic, and that logic needs the script URL kept on the pool object (a Worker instance does not expose its URL):

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

Cap the replacement rate. A script that throws at module-evaluation time will otherwise turn the pool into a respawn loop that pegs a core.

3. Eviction destroys per-worker state

An evicted worker takes its globals with it: a warmed WebAssembly instance, a compiled regular expression cache, a memoisation table, an open IndexedDB handle. The next task lands on a blank isolate and re-does that setup, which can dwarf the 5–15 ms spawn cost — a 3 MB WebAssembly module can take 30–80 ms to instantiate. If workers hold expensive state, either raise minSize so the warm ones keep it, or drop elasticity entirely. The same applies to views over shared memory: a worker holding a SharedArrayBuffer view as described in SharedArrayBuffer & Atomics must be excluded from eviction, or the coordination protocol loses a participant mid-handshake.

4. Destruction must cancel eviction timers, and the queue must have a ceiling

Calling destroy() without clearing idleTimers leaves setTimeout callbacks scheduled against terminated workers; they fire seconds later, call terminate() on a dead handle, and mutate this.idle after teardown. The implementation above clears every timer before terminating — the same discipline that page teardown needs in Handling Worker Termination Gracefully in SPAs.

Neither pool caps queue.length. Once arrivals outrun maxSize, tasks accumulate in memory indefinitely, each holding its payload alive. Add backpressure so overload surfaces as an error the caller can retry or shed:

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

Performance Note

Measured on Chrome 124, MacBook Pro M3 (10 cores), 10 000 tasks of roughly 1 ms synthetic CPU work each, module workers served from cache:

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

The rule of thumb that falls out: elasticity costs about 1–2 % of steady-state throughput and buys back roughly 80 % of idle memory — but it converts a flat p99 into a spiky one. Under sustained load the elastic pool is indistinguishable from the fixed one because it lives at its ceiling; its entire advantage is realised in the seconds when nothing is happening. So the question to answer before choosing is not “which is faster” but “how much of my session is idle, and does anyone notice 15 ms at the head of a burst?” On an interactive surface where bursts are separated by seconds of user think time, that spike hides inside the user’s own reaction time and the memory saving is free. In a batch pipeline, it is pure loss.

Throughput, p99 latency and idle memory for four pool configurations Measured on Chrome 124 on a ten-core MacBook Pro M3 over ten thousand tasks of roughly one millisecond of synthetic CPU work each. A fixed pool of ten workers reaches 9,800 tasks per second at a p99 of 4.2 milliseconds and holds about 30 megabytes constantly. An elastic pool from two to ten under sustained load reaches 9,650 tasks per second at 6.8 milliseconds and also holds about 30 megabytes while active. The same elastic pool under bursty load reaches 9,100 tasks per second, but its first-burst p99 is 18.4 milliseconds — past the 16.7 millisecond frame budget — while idling at about 6 megabytes. A single worker with no pool manages only 980 tasks per second at a p99 of 38.6 milliseconds and 3 megabytes. Chrome 124, MacBook Pro M3 (10 cores) — 10 000 tasks of ~1 ms synthetic CPU work each Throughput — tasks / second p99 latency — ms 16.7 ms frame Fixed pool, 10 always resident ~30 MB idle 9 800 4.2 Elastic 2→10 sustained load ~30 MB idle 9 650 6.8 Elastic 2→10 bursty load ~6 MB idle 9 100 18.4 Single worker no pool ~3 MB idle 980 38.6 0 5 000 10 000 0 20 40 Elasticity costs ~1–2 % of steady-state throughput and buys back ~80 % of idle memory — and turns a flat p99 into a spiky one.
Read the two panels together. Throughput barely separates the three pooled configurations — the gap that matters is between any pool and no pool at all. The story is in the latency panel: only the bursty elastic run crosses the 16.7 ms frame budget, and it does so exactly once per burst, in exchange for the 6 MB idle footprint on its left.

Frequently Asked Questions

How many workers should a fixed-size pool contain?
Start from navigator.hardwareConcurrency minus one, clamped to roughly 2–8. hardwareConcurrency reports logical cores, so a 4-core/8-thread laptop returns 8 and eight compute-bound workers contend for four physical execution units; reserving a thread leaves headroom for rendering and event handling on the main thread. Then measure — on a 10-core M3 (6 performance + 4 efficiency cores) a pool of 6–8 usually beats a pool of 10, because efficiency cores add scheduling cost faster than they add throughput.
When does an elastic pool actually use less memory than a fixed pool?
Only when load is genuinely intermittent. Under a near-constant task stream the pool grows to maxSize within the first burst and stays there, so it costs exactly what a fixed pool costs plus the grow/shrink bookkeeping. The saving is real for interactive surfaces that burst on user input and then sit idle for seconds: shrinking from 8 workers to a floor of 2 releases roughly 18–30 MB of heap on a typical compute worker, and the cost is a 5–15 ms spawn on the first task of the next burst.

See also