Tiny zero-dependency async concurrency limiter
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
justinwilliams.dev 26e19491a5
chore: release 1.0.0 (#2)
Mark the public API stable for 1.x. Bump package and lockfile versions;
document 1.0.0 in CHANGELOG (docs, example, agent skill; no API shape change).

Co-authored-by: Sprite <noreply@sprites.dev>
2026-08-01 11:38:17 -06:00
.github/workflows Initial release of asyncq 2026-07-31 18:28:57 -06:00
examples docs: add runnable fake agent-turn example 2026-08-01 17:09:07 +00:00
skills docs: add bounded-tool-concurrency agent skill 2026-08-01 17:22:02 +00:00
src Release 0.3.1: queue controls and DOM-free types 2026-07-31 19:35:30 -06:00
test Release 0.3.1: queue controls and DOM-free types 2026-07-31 19:35:30 -06:00
.gitignore Initial release of asyncq 2026-07-31 18:28:57 -06:00
CHANGELOG.md chore: release 1.0.0 (#2) 2026-08-01 11:38:17 -06:00
LICENSE Initial release of asyncq 2026-07-31 18:28:57 -06:00
package-lock.json chore: release 1.0.0 (#2) 2026-08-01 11:38:17 -06:00
package.json chore: release 1.0.0 (#2) 2026-08-01 11:38:17 -06:00
README.md docs: add bounded-tool-concurrency agent skill 2026-08-01 17:22:02 +00:00
tsconfig.json Initial release of asyncq 2026-07-31 18:28:57 -06:00
tsup.config.ts Initial release of asyncq 2026-07-31 18:28:57 -06:00
vitest.config.ts Initial release of asyncq 2026-07-31 18:28:57 -06:00

asyncq

npm version CI license bundle size

Tiny zero-dependency async concurrency limiter.

Cap how many promises run at once. Same primitive for everyday JS/TS work (fetches, jobs, backpressure) and for AI agent runtimes (tool calls, subagents, provider fan-out).

import asyncq from "@justinwilliams-io/asyncq";

const limit = asyncq(3);

const results = await Promise.all(
  urls.map((url) => limit(() => fetch(url).then((r) => r.json()))),
);

Install

npm install @justinwilliams-io/asyncq
pnpm add @justinwilliams-io/asyncq
yarn add @justinwilliams-io/asyncq

Why asyncq?

asyncq p-limit
Dependencies 0 1
Size tiny larger
API single function single function
Inspect counts active / pending via separate package
Clear / idle / abort built in partial / extra packages

Same idea as the excellent p-limit, stripped to the essentials, plus clear, dynamic concurrency, idle wait, and abort.

When to use which

  • asyncq if you want zero dependencies, built-in active / pending, clear(), onIdle(), mutable concurrency, and pending-job AbortSignal support in one small API.
  • p-limit if you already depend on the sindresorhus stack, or you only need a minimal limiter and are fine pulling its dependency tree.

Neither replaces a rate limiter (requests per minute, token buckets). Use a concurrency limiter for "how many at once"; pair it with RPM/backoff logic when the API requires it.

Usage

Basic

import asyncq from "@justinwilliams-io/asyncq";
// or: import { asyncq } from "@justinwilliams-io/asyncq";

const limit = asyncq(2);

await limit(async () => {
  // at most 2 of these run at once
});

Rate-limited fetches

import asyncq from "@justinwilliams-io/asyncq";

const limit = asyncq(5);

async function getUser(id: string) {
  return limit(async () => {
    const res = await fetch(`https://api.example.com/users/${id}`);
    if (!res.ok) throw new Error(res.statusText);
    return res.json();
  });
}

const users = await Promise.all(ids.map(getUser));

Inspect the queue

const limit = asyncq(3);

limit(() => doWork());
limit(() => doWork());
limit(() => doWork());
limit(() => doWork());

limit.active;  // 3, currently running
limit.pending; // 1, waiting in queue

Clear pending work

const limit = asyncq(2);

// start a batch…
const jobs = ids.map((id) => limit(() => fetchItem(id)));

// drop the rest of the batch (running jobs keep going)
limit.clear(); // pending promises reject with QueueClearedError

// or drop pending without settling (promises hang; use with care)
limit.clear(false);

Dynamic concurrency

const limit = asyncq(2);

// scale up under load
limit.concurrency = 10;

// scale back down (does not stop in-flight jobs)
limit.concurrency = 2;

Wait until idle

const limit = asyncq(4);

for (const item of items) {
  limit(() => process(item));
}

await limit.onIdle(); // active === 0 && pending === 0

AbortSignal (pending jobs only)

const limit = asyncq(3);
const controller = new AbortController();

const job = limit(() => fetch(url), { signal: controller.signal });

// if still queued, rejects with AbortError and never runs
controller.abort();

// once a job has started, abort does not reject it.
// pass the same signal into fetch/work if you need in-flight cancel

Errors from individual jobs reject only that promise. The queue keeps draining.

AI agents

Agent loops love unbounded Promise.all: too many tool calls, subagents, or model requests at once burns rate limits and makes "stop" hard. asyncq is a plain concurrency limiter. It is not an agent framework. Use it at the edges where fan-out happens.

Agent need asyncq
Cap parallel tool calls asyncq(n)
Wait until the turn's work finishes onIdle()
User hit stop / plan changed clear() and/or AbortSignal on pending jobs
Ease up after 429s, open up when healthy concurrency = …
See load active / pending

Bound parallel tool calls

import asyncq from "@justinwilliams-io/asyncq";

// e.g. at most 4 tools in flight for this turn
const tools = asyncq(4);
const turn = new AbortController();

const results = await Promise.all(
  calls.map((call) =>
    tools(() => runTool(call), { signal: turn.signal }),
  ),
);

Pass turn.signal into the tool implementation as well if in-flight work should cancel, not only queued work.

Separate pools for tools, model calls, and browser

One global limit mixes different bottlenecks. Prefer small named limiters:

import asyncq from "@justinwilliams-io/asyncq";

const tools = asyncq(4);
const llm = asyncq(2);
const browser = asyncq(1); // serial UI / computer-use steps

await Promise.all([
  tools(() => readFile(path)),
  tools(() => search(query)),
  llm(() => complete(messages)),
  browser(() => click(selector)),
]);

await Promise.all([tools.onIdle(), llm.onIdle(), browser.onIdle()]);

Subagents

import asyncq from "@justinwilliams-io/asyncq";

// hard cap so one workflow cannot spawn dozens of children
const agents = asyncq(3);

await Promise.all(
  tasks.map((task) => agents(() => runSubagent(task))),
);

await agents.onIdle();

Stop / tear down a turn

import asyncq, { AbortError, QueueClearedError } from "@justinwilliams-io/asyncq";

const tools = asyncq(4);
const turn = new AbortController();

const pending = calls.map((call) =>
  tools(() => runTool(call), { signal: turn.signal }).catch((err) => {
    if (err instanceof AbortError || err instanceof QueueClearedError) {
      return null; // expected on cancel
    }
    throw err;
  }),
);

// user hit stop:
turn.abort();   // rejects jobs still waiting in the queue
tools.clear();  // same idea for anything enqueued without a signal

await tools.onIdle(); // in-flight tools finish unless they honor the signal

Soften concurrency on provider pressure

const llm = asyncq(4);

async function complete(req: Request) {
  return llm(async () => {
    const res = await callModel(req);
    if (res.status === 429) {
      llm.concurrency = Math.max(1, llm.concurrency - 1);
      // retry / backoff at the call site
    }
    return res;
  });
}

Concurrency caps "how many at once." It does not replace retry-after, token buckets, or provider-specific RPM helpers.

Try it without an LLM

Fake tool/llm/browser pools, optional mid-turn abort:

npm run example:agent
npm run example:agent:abort

See examples/agent-turn.mjs.

Agent skill

This repo ships an Agent Skill that steers coding agents to bound tool fan-out with this package (not a hand-rolled queue) in Node/TS:

Install into Claude Code (or copy to your agent's skills directory):

mkdir -p .claude/skills
cp -R skills/bounded-tool-concurrency .claude/skills/

Details: skills/README.md.

API

asyncq(maxRunning)

Creates a limiter.

Parameter Type Description
maxRunning number Initial max concurrent jobs. Integer >= 1.

Returns a function with the shape:

type LimitOptions = { signal?: AbortSignalLike }; // native AbortSignal works

type AsyncQueue = {
  <T>(fn: () => Promise<T>, options?: LimitOptions): Promise<T>;
  readonly active: number;
  readonly pending: number;
  concurrency: number;
  clear(rejectPending?: boolean): void;
  onIdle(): Promise<void>;
};
limit(fn, options?) Enqueues fn and returns its promise. Runs when a slot is free. FIFO. Optional signal cancels while still pending.
limit.active Jobs currently running.
limit.pending Jobs waiting in the queue.
limit.concurrency Get/set max concurrent jobs (integer >= 1). Increasing starts waiting jobs; decreasing only affects new starts.
limit.clear(rejectPending?) Removes all pending jobs. Default true rejects them with QueueClearedError. false drops without settling. Does not stop active jobs.
limit.onIdle() Resolves when active === 0 and pending === 0. Resolves immediately if already idle.

Throws RangeError if maxRunning / concurrency is not an integer >= 1.

Errors

Class When
AbortError Pending job aborted via AbortSignal, or signal already aborted at enqueue.
QueueClearedError Pending job rejected by clear() / clear(true).
import asyncq, { AbortError, QueueClearedError } from "@justinwilliams-io/asyncq";

License

MIT © justinwilliams.dev