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