Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

poolctl

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.

npm version license

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.

Table of contents

Why poolctl?

  • 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 Pool instance 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. exec returns the task's resolved value, and a thrown error propagates to the awaiter. Composes with plain try/catch and Promise.all — no events to subscribe to, no result arrays to drain.
  • Error-safe by construction. A task's slot is released in a finally block, so a thrown error can never leak a slot or stall the pool.
  • Live introspection. getSize(), getWorkingCount(), and getPendingCount() 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.

Install

npm install poolctl
import { Pool } from "poolctl";

Deno:

import { Pool } from "https://raw.githubusercontent.com/dev-a-loper/task-pool/main/mod.ts";

Examples

Quick start

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. poolctl invokes the function only once a slot is free — that's what keeps concurrency bounded.

One pool, many sources

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.

Tasks that arrive over time

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.

Errors are just rejections

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()); // 0

Mix 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");

Watching the pool

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");

API

new Pool(size)

Create a pool that runs at most size tasks concurrently.

  • size: number — maximum concurrent tasks. Throws if < 1.

pool.exec(task)

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 a finally block, 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"); // string

pool.getSize()

The configured maximum concurrency (size).

pool.getWorkingCount()

How many tasks are running right now.

pool.getPendingCount()

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

How it compares

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:

  • pooledMap and p-map are 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's poolctl's job.
  • p-limit is the closest in spirit to poolctl: ad-hoc, shared budget, with introspection. Pick poolctl if you prefer a class-based API, want dual ESM/CommonJS output, or want a Deno-first origin. They're peers, not rivals.
  • p-queue is far more capable — priority, per-task timeout, pause/resume, rate limiting, events. It's also larger, ESM-only, and has a dependency.

When to reach for something else

poolctl is intentionally minimal. Use a different tool if you need:

  • Priority ordering or a custom queuep-queue
  • Per-task timeouts, pause/resume, or rate limiting (N per second) → p-queue
  • Events or AbortSignal-based cancellationp-queue
  • A bounded map over a known array/iterable → Deno pooledMap or p-map

If "at most N at once, from anywhere, with zero fuss" is all you need — that's poolctl.

How it works

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.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages