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 |
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
idleTimeoutMsconsistently.
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.