Implementing a Simple Worker Pool in Vanilla JS

Spawning a new Worker for every computational task introduces measurable main-thread latency, spikes GC pressure, and fragments memory allocation. A fixed-size pool eliminates per-task thread initialization by recycling pre-warmed execution contexts. This pattern is the practical foundation of Worker Pool Management within the Web Workers Architecture & Communication family of techniques.

1. Core Architecture: Fixed-Size Pool & FIFO Queue

A production pool requires three tightly coupled components:

  • A static array of pre-initialized Worker instances.
  • An internal FIFO queue for pending computational payloads.
  • A synchronous dispatcher that binds queued tasks to idle workers.

Pool size should start at navigator.hardwareConcurrency and be capped at a sensible maximum (commonly 8) to avoid OS-level thread thrashing and excessive memory consumption.

Performance

Worker initialization — network fetch, script parse, and V8 isolate boot — costs 5–15 ms per worker. Pre-warming a pool at application startup amortizes this cost across thousands of tasks. Never spawn workers inside a hot dispatch loop.

2. Step-by-Step Implementation

2.1 Worker Initialization & State Tracking

Each worker requires explicit state tracking to prevent race conditions during rapid dispatch cycles.

// pool.js
function createWorker(id, scriptUrl, pool) {
  const worker = new Worker(scriptUrl);
  worker._poolId = id;
  worker._state = 'idle'; // 'idle' | 'busy' | 'terminating'
  worker.onmessage = (e) => pool.handleWorkerMessage(worker, e.data);
  worker.onerror = (err) => pool.handleWorkerError(worker, err);
  return worker;
}

2.2 Task Queue & Dispatch Logic

The WorkerPool class manages the queue and correlates tasks to pending Promises using a unique taskId. The dispatcher runs synchronously on every enqueue() call to minimize latency.

class WorkerPool {
  constructor(size, scriptUrl) {
    this.workers = Array.from(
      { length: size },
      (_, i) => createWorker(i, scriptUrl, this)
    );
    this.queue = [];
    this.pending = new Map(); // taskId -> { resolve, reject }
  }

  enqueue(task) {
    const promise = new Promise((resolve, reject) => {
      this.pending.set(task.id, { resolve, reject });
    });
    this.queue.push(task);
    this.dispatch();
    return promise;
  }

  dispatch() {
    while (this.queue.length > 0) {
      const idle = this.workers.find(w => w._state === 'idle');
      if (!idle) break;

      const task = this.queue.shift();
      idle._state = 'busy';
      idle.postMessage({ taskId: task.id, payload: task.data });
    }
  }
}

2.3 Message Routing & Promise Resolution

Incoming messages must be routed to the correct Promise resolver. Worker crashes are isolated to prevent pool-wide failure.

// Add these methods to WorkerPool
class WorkerPool {
  handleWorkerMessage(worker, data) {
    const { taskId, result, error } = data;
    const pending = this.pending.get(taskId);

    if (!pending) return; // Already resolved or timed out

    if (error) {
      pending.reject(new Error(error));
    } else {
      pending.resolve(result);
    }

    this.pending.delete(taskId);
    worker._state = 'idle';
    this.dispatch(); // Process next queued item immediately
  }

  handleWorkerError(worker, err) {
    console.error(`Worker ${worker._poolId} crashed:`, err.message);
    // Reject any task assigned to this worker
    // (In a real implementation, track which task each worker is running)
    worker._state = 'idle';
    this.dispatch();
  }
}

3. Memory & Serialization Trade-offs

Passing large datasets via postMessage triggers structuredClone serialization on both threads. This blocks the main thread and spikes heap usage.

Data Size Serialization Overhead Recommended Strategy
< 5 MB Negligible (<1ms) Standard postMessage
5–50 MB 2–8ms (GC pressure) Chunk payloads, batch dispatch
> 50 MB >10ms (jank risk) Transferable objects (zero-copy)

Zero-copy transfers bypass serialization entirely by moving ownership of ArrayBuffer instances. Apply Worker Pool Management heuristics when memory-constrained SPAs require strict heap budgets.

// Zero-copy dispatch example
const buffer = new ArrayBuffer(1024 * 1024 * 60); // 60MB
worker.postMessage({ taskId: 'heavy', payload: buffer }, [buffer]);
// `buffer` is now detached on the main thread (byteLength === 0). Do not access it.

4. Step-by-Step Diagnostics & Performance Tuning

4.1 DevTools Profiling for Thread Starvation

Thread starvation occurs when queue depth consistently exceeds worker capacity:

  1. Open Chrome DevTools > Performance tab.
  2. Record a session with worker threads enabled (click the gear icon, check “Include worker threads”).
  3. Look for sustained idle gaps in the worker tracks or postMessage serialization spikes on the main thread.
  4. Instrument code: performance.measure('pool-latency', 'enqueue-start', 'resolve-end').
  5. Sort by Self Time in the Bottom-Up view to isolate the costliest operations.

4.2 Backpressure & Queue Depth Monitoring

Unbounded queues trigger V8 heap expansion and main-thread jank. Implement explicit backpressure thresholds.

class WorkerPool {
  enqueue(task) {
    const BACKPRESSURE_LIMIT = this.workers.length * 3;
    if (this.queue.length >= BACKPRESSURE_LIMIT) {
      return Promise.reject(new Error('Queue full — apply backpressure'));
    }

    const promise = new Promise((resolve, reject) => {
      this.pending.set(task.id, { resolve, reject });
    });
    this.queue.push(task);
    this.dispatch();
    return promise;
  }
}

5. Graceful Termination & Resource Cleanup

Detached workers leak memory in long-running dashboards. Implement a deterministic drain sequence.

class WorkerPool {
  isDraining = false;

  async drain() {
    this.isDraining = true;
    // Wait for all in-flight tasks to settle
    const inFlight = [...this.pending.values()].map(
      ({ resolve, reject }) =>
        new Promise((res) => {
          const original = { resolve, reject };
          // Wrap so drain can observe completion
          resolve = (v) => { original.resolve(v); res(); };
          reject = (e) => { original.reject(e); res(); };
        })
    );
    await Promise.allSettled(inFlight);

    this.workers.forEach(worker => {
      worker._state = 'terminating';
      worker.terminate();
    });
    this.workers = [];
    this.pending.clear();
  }
}

// Bind to page lifecycle
window.addEventListener('beforeunload', () => pool.drain());

6. When to Scale Beyond Vanilla

Vanilla postMessage pools excel for batch processing but hit hard limits in sub-16ms real-time rendering pipelines. Transition to SharedArrayBuffer with Atomics for lock-free concurrent reads when cross-thread synchronization latency exceeds a few milliseconds.

COOP / COEP required for SharedArrayBuffer

Enabling SharedArrayBuffer for lock-free inter-worker communication requires serving the page with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without these headers, SharedArrayBuffer is undefined in all contexts. See SharedArrayBuffer & Atomics for the full setup guide.

For server-side or heavy I/O workloads, evaluate Node.js worker_threads. Measure actual frame budgets before adopting complex shared-memory architectures — the added synchronization complexity is only justified when profiling shows it is the bottleneck.

Frequently Asked Questions

How do I choose the right pool size for CPU-bound tasks?
Start at navigator.hardwareConcurrency — one worker per logical CPU core. Cap the maximum at 8 to avoid OS-level thread thrashing on constrained devices. Exceeding physical core counts increases scheduler overhead without proportional throughput gains. Profile under realistic load and shrink the pool if idle workers consistently outnumber active ones.
What happens to queued tasks when a worker in the pool crashes?
The pool’s onerror handler fires on the faulting worker. Any task that was dispatched to that worker and not yet resolved has its Promise left pending. A robust implementation maps each busy worker to its current taskId, so the handler can immediately reject the orphaned Promise and return the worker to the idle list to continue serving queued tasks.

See also