npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

poolctl

v0.1.1

Published

A simple concurrency-limiting task pool for JavaScript. Runs at most N async tasks at a time.

Readme

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