Implementing a Simple Worker Pool in Vanilla JS

A pool is not a clever data structure — it is four pieces of state kept consistent under crashes, late replies and teardown, and this page builds the smallest version that still gets all four right.

The sizing theory, priority scheduling and telemetry belong to Worker Pool Management within the Web Workers Architecture & Communication reference; what follows is the concrete artefact — around 70 lines of plain JavaScript you can paste into a page with no bundler, no TypeScript toolchain and no dependencies, and which does not fall over the first time a worker throws.

The Four Moving Parts

Almost every broken pool in the wild is missing one of these, and the failure it produces is specific enough to name:

State Holds What goes wrong without it
queue (array) Task records waiting for capacity Work is dropped or dispatched out of order under burst load
idle (array) Workers currently holding no task Two tasks are posted to the same worker and serialize behind each other
inFlight (Map, id → task) The resolver for every dispatched task Late or duplicate replies resolve the wrong caller’s promise
assignment (Map, worker → id) Which worker owns which task A crash leaves the caller’s promise pending forever

The assignment map is the piece most tutorials omit. Without it, the error event tells you that a worker died but not what it was doing, so there is no promise to reject and the caller waits on a value that can never arrive.

The four structures, and the failure each one prevents Four labelled containers. Top left, the queue array holds tasks 6, 7 and 8 in submission order; shift takes the head, push adds at the tail. Top right, the idle array holds workers A, C and D, with a dashed empty slot where worker B would be, because B is busy with task 5. Bottom left, the inFlight map keys the record for task 5, holding its payload and its resolve and reject callbacks. Bottom right, the assignment map records that worker B owns task id 5; workers A, C and D have no entry because they are idle. A double-headed arrow links the two map rows: id 5 appears in both at once, which is the only state that survives a crash. Without the queue, work is dropped or reordered; without the idle array, two tasks land on one worker; without inFlight, a late reply settles the wrong promise; without assignment, a crash leaves its caller pending forever. queue — PoolTask[] task #6 task #7 task #8 FIFO — shift() the head, push() at the tail Missing: work dropped or reordered under burst idle — Worker[] worker A worker C worker D worker B busy · task #5 LIFO — pop() reuses the hottest isolate Missing: two tasks land on one worker inFlight — Map<id, task> id task record 5 { payload, resolve, reject } 6 { payload, resolve, reject } Missing: a late reply settles the wrong promise assignment — Map<Worker, id> worker task id worker B 5 A, C and D are idle — no entry Missing: a crash leaves its caller pending forever inFlight.set(id, task) assignment.set(worker, id) id 5
One task id, held in two places at once. inFlight owns the resolver; assignment records which worker is carrying that id — and worker B is correspondingly absent from idle. When B's error event fires, assignment.get(worker) is the only path back to the promise that must be rejected, which is why the map most tutorials omit is the one that makes the crash path possible at all.

Minimal Reproducible Example

Three files, all loadable straight from a static server. The pool is an ES module; the worker stays a classic script so it needs no build step at all.

// pool.js — a complete fixed-size Worker pool, dependency-free.

/**
 * @typedef {Object} PoolTask
 * @property {number} id
 * @property {unknown} payload
 * @property {Transferable[]} transfer
 * @property {Promise<unknown>} done
 * @property {(value: unknown) => void} resolve
 * @property {(reason: Error) => void} reject
 */

/** One worker per logical core, minus a thread for the main thread. */
function defaultSize() {
  return Math.max(2, Math.min(8, (navigator.hardwareConcurrency || 4) - 1));
}

export class WorkerPool {
  /**
   * @param {string | URL} scriptUrl Prefer `new URL('./x.worker.js', import.meta.url)`.
   * @param {number} [size]
   */
  constructor(scriptUrl, size = defaultSize()) {
    this.scriptUrl = scriptUrl;
    this.nextId = 1;
    this.closed = false;
    /** @type {PoolTask[]} */ this.queue = [];
    /** @type {Worker[]} */ this.idle = [];
    /** @type {Map<number, PoolTask>} */ this.inFlight = new Map();
    /** @type {Map<Worker, number>} */ this.assignment = new Map();

    for (let i = 0; i < size; i += 1) this.idle.push(this.#spawn());
  }

  #spawn() {
    const worker = new Worker(this.scriptUrl);
    worker.addEventListener('message', (e) => this.#onMessage(worker, e.data));
    worker.addEventListener('error', (e) => this.#onError(worker, e));
    return worker;
  }

  /** @returns {Promise<unknown>} settles when this exact task finishes. */
  run(payload, transfer = []) {
    if (this.closed) return Promise.reject(new Error('pool is draining'));
    /** @type {PoolTask} */
    const task = { id: this.nextId++, payload, transfer };
    task.done = new Promise((resolve, reject) => {
      task.resolve = resolve;
      task.reject = reject;
    });
    this.queue.push(task);
    this.#dispatch();
    return task.done;
  }

  #dispatch() {
    while (this.queue.length > 0 && this.idle.length > 0) {
      const worker = this.idle.pop();
      const task = this.queue.shift();
      this.inFlight.set(task.id, task);
      this.assignment.set(worker, task.id);
      worker.postMessage({ id: task.id, payload: task.payload }, task.transfer);
    }
  }

  #onMessage(worker, data) {
    const task = this.inFlight.get(data.id);
    this.inFlight.delete(data.id);
    this.assignment.delete(worker);
    this.idle.push(worker);       // capacity comes back BEFORE the promise settles
    this.#dispatch();
    if (!task) return;            // a reply for a task nobody is waiting on
    if (data.error) {
      const err = new Error(data.error.message);
      err.name = data.error.name;
      err.workerStack = data.error.stack;
      task.reject(err);
    } else {
      task.resolve(data.result);
    }
  }

  #onError(worker, event) {
    const id = this.assignment.get(worker);
    this.assignment.delete(worker);
    const at = this.idle.indexOf(worker);
    if (at !== -1) this.idle.splice(at, 1);   // it may have died while idle
    worker.terminate();
    this.idle.push(this.#spawn());            // keep capacity constant

    const task = id === undefined ? undefined : this.inFlight.get(id);
    if (task) {
      this.inFlight.delete(id);
      task.reject(new Error(event.message || 'worker terminated unexpectedly'));
    }
    this.#dispatch();
  }

  /** Settle everything already accepted, then tear the pool down. */
  async drain() {
    this.closed = true;
    while (this.queue.length > 0 || this.inFlight.size > 0) {
      const live = [...this.queue, ...this.inFlight.values()].map((t) => t.done);
      await Promise.allSettled(live);
    }
    for (const worker of [...this.idle, ...this.assignment.keys()]) worker.terminate();
    this.idle.length = 0;
    this.assignment.clear();
  }
}
// task.worker.js — a classic worker: no modules, no imports, no build step.
'use strict';

self.addEventListener('message', (event) => {
  const { id, payload } = event.data;
  try {
    self.postMessage({ id, result: compute(payload) });
  } catch (err) {
    // Flatten the error: reply on the same channel so the id still correlates.
    self.postMessage({ id, error: { name: err.name, message: err.message, stack: err.stack } });
  }
});

function compute({ values }) {
  let acc = 0;
  for (let i = 0; i < values.length; i += 1) acc += Math.sqrt(values[i]) * Math.log1p(values[i]);
  return acc;
}
<!-- index.html — 64 batches across the pool, in parallel, without a bundler -->
<script type="module">
  import { WorkerPool } from './pool.js';

  const pool = new WorkerPool(new URL('./task.worker.js', import.meta.url));
  const batches = Array.from({ length: 64 }, () => ({
    values: Float64Array.from({ length: 250_000 }, (_, i) => i),
  }));

  const t0 = performance.now();
  const sums = await Promise.all(batches.map((b) => pool.run(b)));
  console.log(sums.length, 'batches in', (performance.now() - t0).toFixed(1), 'ms');

  addEventListener('pagehide', () => { void pool.drain(); });
</script>
Three files, one thread boundary, and where the worker URL resolves On the document side, index.html runs a module script that constructs the pool; it imports pool.js, an ES module holding the class and the four structures. An arrow crosses the dashed thread boundary from pool.js to a panel of four live worker isolates, each booted from the single classic script task.worker.js. The call-out at the bottom contrasts the two URL forms: passing the bare string task.worker.js resolves it against the document, so the browser requests it from the site root; passing new URL of dot slash task.worker.js with import.meta.url resolves it against pool.js instead, and behaves identically with and without a bundler. document thread thread boundary worker threads index.html <script type="module"> — no bundler await Promise.all(batches.map(pool.run)) import { WorkerPool } pool.js — ES module class WorkerPool { … } queue · idle · inFlight · assignment #spawn() → new Worker(scriptUrl) spawn ×4 task.worker.js classic worker — no imports, no build step onmessage → postMessage({ id, result }) one script, four instances 4 live isolates, one script each worker 0 worker 1 worker 2 worker 3 idle busy idle idle The one rule you cannot skip — where the worker URL resolves ✗ new Worker('task.worker.js') → resolved against the document: GET /task.worker.js ✓ new Worker(new URL('./task.worker.js', import.meta.url)) → resolved against pool.js Identical with and without a build step — the module that asks, not the page that loads it, owns the path.
Three files and one boundary. Only pool.js ever crosses it, and it crosses four times at construction. The bare-string form in the first row is the silent failure: a pool module living in /js/ requests /task.worker.js from the site root, gets a 404 HTML document back, and every task hangs with nothing the caller can see.

Step-by-Step Walkthrough

run() stores the promise on the task, not in a closure. Building task.done and capturing resolve/reject onto the record means every later stage — dispatch, message, crash, drain — can settle a task from a plain object it looked up in a Map, with no executor scope to reach back into. drain() gets this for free: it awaits task.done for records it never created.

#dispatch() is a while loop, not an if. One completion can unblock exactly one task, but one run() call after a burst of completions can unblock several. Draining the queue on every invocation keeps a single code path correct in both cases, and because it runs synchronously inside run(), a task submitted to an idle pool is posted in the same task tick — no setTimeout, no microtask hop, no artificial latency.

idle.pop() and queue.shift() are deliberate opposites. The queue is FIFO, so callers are served in submission order. The idle set is LIFO, so the most recently used worker is reused first; its script, its JIT-warmed functions and its heap are the ones still resident in cache. Popping the coldest worker instead measurably lengthens the first task after an idle period.

The message handler returns the worker before it settles the promise. task.resolve() synchronously runs whatever await continuation the caller had parked. If that continuation calls pool.run() again — the common chained-pipeline shape — and the worker has not yet been pushed back onto idle, the new task lands in the queue and waits for another completion that may never come. Returning capacity first makes that reentrancy safe.

An unknown data.id is dropped, not thrown on. After a timeout, a cancellation or a crash-and-replace, a reply can still arrive for a task nobody owns. Looking the id up and returning when it is missing is the entire cancellation story for this pool.

Why capacity is returned before the promise settles Three lifelines: caller, pool and worker A. The caller calls run with batch 1, and the pool queues the task. Dispatch pops worker A from the idle array and posts the payload, worker A computes, and posts a reply carrying the task id. The pool then deletes the entry from inFlight, pushes worker A back onto idle and runs dispatch, all before it calls task.resolve. Only then does the caller's awaited continuation run, and its follow-up call to run for batch 2 is dispatched to worker A immediately, with no queue wait. The panel underneath shows the reversed order: if resolve runs first, the continuation calls run while the idle array is still empty, so the second task is queued behind a completion that may never arrive. CALLER POOL WORKER A 1. run(batch 1) — queued as task #1 2. idle.pop() → postMessage compute() runs 3. postMessage({ id: 1, result }) 4. inFlight.delete(1) · idle.push(A) 5. #dispatch() — capacity is back …before anything settles 6. task.resolve(result) 7. await continuation runs → calls pool.run(batch 2) 8. run(batch 2) 9. dispatched at once to worker A queue wait: 0 ms — the pool was never saturated Steps 4–6 in the other order — resolve() before idle.push() resolve() runs the caller's continuation first, so step 8 arrives while the idle array is still empty. Batch 2 is queued behind a completion that may never come — the reentrant stall this ordering avoids.
Steps 4 and 5 are the whole point of the ordering. task.resolve() hands control straight back to the caller's parked await, synchronously — so any state the pool has not finished updating is state the caller sees stale. Returning the worker and re-running #dispatch() first is what makes a chained pipeline, where each result immediately submits the next task, safe by construction.

Gotchas & Edge Cases

A worker can fail before it ever takes a task

A syntax error or a throw at the top level of the worker script fires error while the instance is still sitting in idle. If the handler assumes the faulting worker was busy, it terminates a dead worker, spawns a replacement, and leaves the corpse in the idle array — capacity silently grows by one and every task dispatched to the dead entry vanishes. The idle.indexOf(worker) splice above is what makes the crash path correct in both states.

Uncaught async errors never reach worker.onerror

The error event fires for uncaught synchronous exceptions. A rejected promise inside the worker — a failed fetch, an async parse — fires unhandledrejection in the worker’s own scope and produces no main-thread event at all, so the task’s promise stays pending forever. Report it on the same message channel so the id still correlates:

// task.worker.js — catch what the error event cannot see.
let currentTaskId = null;   // set at the top of the message handler, cleared after the reply

self.addEventListener('unhandledrejection', (event) => {
  event.preventDefault();
  if (currentTaskId === null) return;
  self.postMessage({
    id: currentTaskId,
    error: { name: 'UnhandledRejection', message: String(event.reason) },
  });
  currentTaskId = null;
});

An unbounded queue is a memory leak with good manners

Nothing in the pool refuses work. Feed it faster than it drains — a scroll handler, a websocket firehose — and the queue holds every payload alive, along with every closure the caller attached to the pending promise. Ten thousand queued 1 MB payloads is 10 GB of retained heap and an OOM tab, and it will not look like a worker bug in a profile. Reject beyond a ceiling proportional to pool size and let the caller decide:

run(payload, transfer = []) {
  if (this.queue.length >= this.idle.length * 4 + 32) {
    return Promise.reject(new Error('pool queue full — apply backpressure'));
  }
  // …
}

Transferables and detachment

run(payload, [payload.buffer]) moves a buffer instead of copying it, which is the difference between a constant sub-0.05 ms handoff and a copy that scales with size — see Transferable Objects & Zero-Copy. The catch is that the pool holds the task record until the reply arrives, and after a transfer the payload in that record is detached: retrying a failed task by re-posting the same object sends a zero-length buffer. Either keep transfers non-retryable, or have the worker transfer the buffer back in its reply and rebuild the task from that.

`file://` will not load a worker

Opening the page directly from disk fails at new Worker(...) with a DOMException, because file:// origins are opaque and the worker script is treated as cross-origin. This is the single most common "my pool does nothing" report. Serve the three files over HTTP — npx serve . is enough — before assuming the code is wrong.

One worker's lifecycle, including both crash paths A worker moves from spawning to idle once its script has been fetched and parsed, from idle to busy when the dispatcher assigns it a task, and back to idle when its reply is handled and it is pushed onto the idle array. Two separate error transitions lead to the dead state, drawn in the accent colour because naive pools implement neither correctly: an error raised while the worker is busy must reject the task id recorded in the assignment map, while an error raised while the worker is still idle must instead splice the instance out of the idle array so a dead entry is never dispatched to. From dead, the pool terminates the instance and spawns a replacement so capacity stays constant. A dashed transition shows a late reply carrying an unknown id being looked up in inFlight, found missing, and dropped without touching the worker's state. late reply, unknown id inFlight.get(id) → undefined → dropped new Worker(url) spawning script fetch + parse idle sitting in idle[] busy assignment.set() ready dispatch reply idle.push() dead — worker.terminate() removed from every structure, then replaced error while busy: reject the assigned id error while idle: splice it out of idle[] #spawn() replacement capacity stays constant
The two pumpkin edges are the ones tutorials collapse into a single handler. They need opposite bookkeeping: a busy worker owns a task id that must be rejected, while an idle worker owns nothing but occupies a slot that must be spliced out. Handle only the first and a dead instance stays in idle; handle only the second and the caller's promise never settles.

Performance Note

The pool exists to amortise one number: constructing a Worker costs roughly 5–15 ms on a desktop browser and 12–30 ms on a mid-tier phone, covering the script fetch, parse and a fresh V8 isolate boot. Spawning per task for the 64-batch example above spends 320–960 ms on isolate startup alone; four pre-warmed workers spend it once, at 20–60 ms total, and every task afterwards pays only the message hop.

That hop is the second number worth memorising. A round trip through postMessage with a small payload has a floor of roughly 0.1–0.5 ms, so a task whose body runs for less time than that gets slower by being pooled. The rule of thumb: batch work until each task costs 5–50 ms of compute. Below 1 ms the dispatch overhead dominates; above roughly 50 ms a single task can outlive a cancellation the user has already triggered, and the queue stops feeling responsive.

For payloads large enough that structured clone shows up in a profile, measure before redesigning — the copy is often smaller than assumed, and the mechanics are laid out in the Step-by-Step Guide to the Structured Clone Algorithm.

What the pool amortises, and the batch size that makes it worth it Two stacked bars over a wall-clock axis. Spawning one worker per task pays roughly 640 milliseconds of isolate construction for 64 batches, inside the 320 to 960 millisecond range quoted in the text, before about 260 milliseconds of actual compute, totalling around 900 milliseconds. A pre-warmed pool of four workers pays construction once, roughly 40 milliseconds, then the same 260 milliseconds of compute plus about 6 milliseconds of message hops, totalling around 306 milliseconds. Below, a logarithmic per-task scale runs from 0.1 to 500 milliseconds: the shaded region from 0.1 to 0.5 milliseconds marks the round-trip floor, where a task is slower for being pooled, and the highlighted band from 5 to 50 milliseconds marks the batch size to aim for, above which a single task can outlive a cancellation the user has already triggered. Total wall clock — the same 64 batches of 250,000 values isolate construction compute message hops worker per task 64 isolates 64 × new Worker() ≈ 640 ms compute 260 ms ≈900 ms 40 ms pre-warm, paid once pooled — 4 workers spawned once compute 260 ms ≈306 ms plus ≈6 ms of message hops in total — the mustard sliver 0 250 500 750 1000 ms wall clock, milliseconds Per-task compute time — where pooling starts to pay round-trip floor batch to land here outlives a cancellation 0.1 ms 1 ms 5 ms 50 ms 500 ms
The top pair is the number the pool exists to amortise: construction moves from once per task to once per page. The bottom scale is the number that decides whether to pool at all — under about 1 ms of compute per task the round trip costs more than the work itself, and past roughly 50 ms a task outlives the interaction that asked for it.

Where This Pool Stops Being Enough

Fixed size is the right default and the reason this implementation stays short. Once load is genuinely bursty — idle for seconds, then forty tasks at once — the trade-off between pre-warmed memory and first-task latency becomes a real decision, worked through with measurements in Dynamic vs Fixed-Size Worker Pools. Once tasks must be cancelled mid-flight, or coordinated between workers rather than merely distributed, message passing is the wrong primitive and shared memory with SharedArrayBuffer & Atomics is the next step — at the cost of serving the page with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp.

Everything else this pool lacks — priority ordering, per-task timeouts, latency telemetry — is an addition to the same four structures rather than a rewrite of them, which is the point of getting the small version exactly right first.

Where this pool stops, and what each next step costs Four rows. The fixed pool built on this page is the baseline: no extra code beyond the seventy lines, the queue, idle array and two maps as its only state, suited to steady load with roughly uniform tasks, and it needs nothing but a static server. An elastic pool adds around forty lines of spawn and retire policy plus a high-water mark and idle timers, and pays off when seconds of idle time are followed by sudden bursts, but needs a latency budget to tune against. A priority queue adds around thirty lines of binary heap plus a priority band and arrival sequence per task, and pays off when interactive work must jump ahead of batch work, but needs an ageing rule so the low band cannot starve. Shared memory is a rewrite rather than an addition: an Atomics protocol over one buffer, worth it only when workers must coordinate rather than merely divide work, and it requires the document to be cross-origin isolated with COOP and COEP headers. The first three rows add to the same four structures. The last one replaces them. extra code extra state worth it when requires fixed pool built on this page the ~70 lines above — the baseline queue, idle, inFlight, assignment load is steady and tasks are uniform nothing — a static server is enough elastic pool grow and retire + ~40 lines of spawn/retire policy + high-water mark, idle timers seconds idle, then 40 tasks at once a latency budget to tune against priority queue bands + tie-break + ~30 lines of binary heap + priority band and arrival sequence interactive work must jump the batch an ageing rule so the tail cannot starve shared memory SharedArrayBuffer a rewrite, not an addition + an Atomics protocol over 1 buffer workers coordinate, not merely divide COOP + COEP cross-origin isolated
Read the first column as the honest price. Elastic sizing and priority bands are additions to the same four structures — you keep every line above and grow it. Shared memory is the one row that is not: it changes the primitive from messages to memory, and it changes how the whole document is served.

Frequently Asked Questions

Do I need a bundler or a module worker to build a pool in plain JavaScript?
No. The pool object itself can be an ES module the browser loads directly with <script type="module">, and the worker script can stay a classic worker that uses importScripts() for anything it needs. The only rule you cannot ignore is URL resolution: a bare string passed to new Worker('task.worker.js') resolves against the document URL, not against the module that called it, so a pool module living in /js/ will silently request /task.worker.js. Pass new URL('./task.worker.js', import.meta.url) instead and the path is resolved against the module, which works identically with and without a build step.
Why key in-flight tasks by id instead of storing the promise on the worker object?
Because a worker can produce a reply you no longer want and stop producing one you do. If the resolver lives on the worker instance, a late message from a task that already timed out resolves whatever task that worker picked up next, and a crashed worker takes its resolver into the grave with it, leaving the caller’s promise pending forever. A Map keyed by a monotonic task id survives both: an unknown id is simply dropped, and a crash looks up the id the faulting worker was assigned so the correct promise is rejected before a replacement is spawned.

See also