A tiny, dependency-free concurrency pool for JavaScript and TypeScript. Run at most N async tasks at once — call
pool.exec(task)from anywhere, anytime.
Most concurrency helpers want your work bundled into an array or an iterator up
front. poolctl doesn't. You create one pool with a hard limit and call
exec(task) wherever the work happens to originate — now, later, or from ten
different files. Everything that goes through the same pool shares one budget.
- Ad-hoc, not array-bound. You don't collect tasks into an array or wire up
an iterator. Just
await pool.exec(() => doWork())at the call site — even for tasks that arrive over time (requests, events, streams, a message loop) where you don't know the full set of work ahead of time. - One pool, many sources, one limit. A single
Poolinstance enforces one global concurrency budget. Import it anywhere; every caller — wherever it lives — draws from the same limit. No coordination, no manual counting. - It's just
await.execreturns the task's resolved value, and a thrown error propagates to theawaiter. Composes with plaintry/catchandPromise.all— no events to subscribe to, no result arrays to drain. - Error-safe by construction. A task's slot is released in a
finallyblock, so a thrown error can never leak a slot or stall the pool. - Live introspection.
getSize(),getWorkingCount(), andgetPendingCount()tell you exactly how full the pool is — handy for backpressure decisions and monitoring. - Fair FIFO ordering. When the pool is full, waiters are released in the order they arrived — predictable, starvation-free scheduling.
- Tiny and dependency-free. Roughly 60 lines of source, 0 runtime dependencies, ~3 kB published. Easy to read in full, easy to audit.
- Runs everywhere. Pure Promise logic with no platform APIs, so it works in Deno, Node, browsers, and workers. Ships full TypeScript types and supports both ESM and CommonJS consumers.
npm install poolctlimport { Pool } from "poolctl";Deno:
import { Pool } from "https://raw.githubusercontent.com/dev-a-loper/task-pool/main/mod.ts";Cap concurrent requests without collecting them by hand:
import { Pool } from "poolctl";
const pool = new Pool(5); // at most 5 tasks run at once
const urls = [
"https://example.com/1",
"https://example.com/2",
"https://example.com/3",
// ...thousands more
];
// Never more than 5 requests in flight, no matter how long `urls` is.
await Promise.all(urls.map((url) => pool.exec(() => fetch(url))));Pass a function (
() => fetch(url)), not the promise.poolctlinvokes the function only once a slot is free — that's what keeps concurrency bounded.
Different parts of your code, even different modules, can funnel through one limit. A single import gives you an app-wide budget:
// dbPool.ts — one budget for database calls, process-wide
import { Pool } from "poolctl";
export const dbPool = new Pool(10);// anywhere in your app — both calls count against the same limit of 10
import { dbPool } from "./dbPool";
await dbPool.exec(() => query("SELECT ..."));
await dbPool.exec(() => query("INSERT ..."));You get a global "no more than 10 DB operations at once" guarantee across the entire process, with no shared state to wire up.
This is where poolctl pulls away from iterable-based tools like Deno's
pooledMap or p-map. When work shows up as events or requests — not a
known array — you still get a hard concurrency limit:
import { Pool } from "poolctl";
// At most 5 expensive operations run at once across ALL incoming requests,
// even under heavy concurrent traffic. Extra requests simply queue.
const pool = new Pool(5);
server.on("request", async (req, res) => {
try {
const data = await pool.exec(() => expensiveWork(req));
res.send(data);
} catch (err) {
res.status(500).send(String(err));
}
});There's no "add everything to an array first" step — each request schedules itself as it arrives, and the pool does the rest.
A throwing task rejects the exec promise like any normal async call, and its
slot is always released — so one failure can't clog the pool:
import { Pool } from "poolctl";
const pool = new Pool(2);
try {
await pool.exec(async () => {
throw new Error("boom");
});
} catch (err) {
console.error(err); // Error: boom
}
// The slot was freed despite the throw — the pool is still fully usable.
console.log(pool.getWorkingCount()); // 0
console.log(pool.getPendingCount()); // 0Mix it with Promise.allSettled to run a batch and collect every outcome,
successful or not:
const results = await Promise.allSettled(
items.map((item) => pool.exec(() => process(item))),
);
const ok = results.filter((r) => r.status === "fulfilled");
const failed = results.filter((r) => r.status === "rejected");The live counters make backpressure and progress monitoring trivial:
import { Pool } from "poolctl";
const pool = new Pool(8);
const items = Array.from({ length: 500 }, (_, i) => i);
const tick = setInterval(() => {
console.log(
`running ${pool.getWorkingCount()}/${pool.getSize()} · queued ${pool.getPendingCount()}`,
);
}, 100);
await Promise.all(items.map((i) => pool.exec(() => fetch(`https://example.com/${i}`))));
clearInterval(tick);
console.log("done");Create a pool that runs at most size tasks concurrently.
size: number— maximum concurrent tasks. Throws if< 1.
Run a task within the pool's limit. If the pool is full, the call waits (FIFO) until a slot frees up.
task: () => Promise<T>— a function returning the work to do.- Returns
Promise<T>— resolves with the task's result, or rejects with its error. The slot is released in afinallyblock, so it's always freed — even when the task throws.
In TypeScript, the return type is inferred from the task, so results stay typed end-to-end:
const id = await pool.exec(async () => 42); // number
const name = await pool.exec(async () => "x"); // stringThe configured maximum concurrency (size).
How many tasks are running right now.
How many tasks are queued, waiting for a slot.
Full generated type docs: https://doc.deno.land/https://raw.githubusercontent.com/dev-a-loper/task-pool/main/mod.ts
| Capability | poolctl | Deno pooledMap |
p-limit |
p-queue |
|---|---|---|---|---|
| Usage style | ad-hoc pool.exec(fn) |
one-shot map over an iterable | limit(fn) wrapper |
ad-hoc queue.add(fn) |
| Call from anywhere; tasks arrive over time | ✅ | ❌ needs all inputs up front | ✅ | ✅ |
| One shared budget across many sources | ✅ | ➖ per single call | ✅ | ✅ |
Get each result via await |
✅ | async iterable (input order) | ✅ | ✅ |
| Live working / pending counts | ✅ | ❌ | ✅ | ✅ |
| Priority · timeout · pause · rate-limit · events | ❌ | ❌ | ❌ | ✅ |
| Runtime dependencies | 0 | 0 (std) | 0 | EventEmitter3 |
| ESM and CommonJS | ✅ | ESM | ESM only | ESM only |
| First-class Deno | ✅ | ✅ | via npm: |
via npm: |
| Footprint | ~3 kB | bundled with Deno | ~2 kB | larger |
A few honest notes:
pooledMapandp-mapare excellent when you have a known collection to transform with bounded concurrency and want results in order. They're not designed to be a long-lived pool you call into from arbitrary places over time — that'spoolctl's job.p-limitis the closest in spirit topoolctl: ad-hoc, shared budget, with introspection. Pickpoolctlif you prefer a class-based API, want dual ESM/CommonJS output, or want a Deno-first origin. They're peers, not rivals.p-queueis far more capable — priority, per-task timeout, pause/resume, rate limiting, events. It's also larger, ESM-only, and has a dependency.
poolctl is intentionally minimal. Use a different tool if you need:
- Priority ordering or a custom queue →
p-queue - Per-task timeouts, pause/resume, or rate limiting (N per second) →
p-queue - Events or AbortSignal-based cancellation →
p-queue - A bounded map over a known array/iterable → Deno
pooledMaporp-map
If "at most N at once, from anywhere, with zero fuss" is all you need — that's
poolctl.
poolctl keeps a running count of in-flight tasks and a FIFO queue of waiters
(a hand-rolled linked list, so enqueue/dequeue are O(1)). When exec is
called on a full pool, it creates a deferred promise, parks it at the back of
the queue, and awaits it. When a running task finishes, the waiter at the front
of the queue is resolved and starts running. Because the slot is released in a
finally block, it's always freed — even if the task throws.
The whole thing is ~60 lines; the source is the best documentation.
MIT