p-queue
p-queue is an in-memory promise queue with a concurrency cap and, optionally, a rate cap. You create a queue with new PQueue({concurrency: 4}), push functions into it with queue.add(() => doWork()), and the queue decides when each one runs. On top of the concurrency limit it gives you per-task priorities, per-task timeouts, AbortSignal cancellation, pause and resume, live counters for queued and running work, and an EventEmitter interface so you can watch tasks start, finish, and fail. The intervalCap and interval options add a second constraint: at most n tasks started per time window, which is how you keep an API client under a published rate limit. Everything lives in one process and disappears when the process does. There is no persistence, no workers, and no cross-machine coordination.
The right tool when your concurrency problem has grown a second dimension: rate limits, priorities, cancellation, or pause. If you only need "n at a time", p-limit does that job smaller, and anything that must outlive the process belongs in a real job queue.
Use it if
- You are calling an API that publishes both a concurrency ceiling and a requests-per-window limit, and you need one object that enforces both at the same time
- Some queued work matters more than the rest: priorities plus setPriority let you push a user-triggered job ahead of a batch of background jobs already waiting
- You need control over in-flight work at runtime: pause and resume around a deploy, cancel queued tasks with an AbortController, or apply a per-task timeout so a hung request stops holding a concurrency slot
- You want backpressure on a producer that outruns the consumer, using onSizeLessThan or onRateLimit to stop enqueuing before memory grows without bound
- All you need is "run these n at a time": p-limit is about a quarter of the size, has no event emitter, and is the smaller correct answer. The README itself points p-map users away from p-queue
- You need jobs to survive a restart, run across multiple machines, or retry with a dead-letter queue. p-queue is a variable in one Node process, so use BullMQ, Redis, or an actual job runner
- Your project is CommonJS: v7 and later are ESM only, and v9 requires Node.js 20 or newer. The README refuses issues about CommonJS, so old codebases are stuck on p-queue@6
- You expect queue.clear() to be a cancel button. Promises returned by add() for cleared tasks never settle, so an awaiting Promise.all hangs forever. You have to use AbortSignal instead, which is more wiring than most people expect
- You expect completion order to match insertion order. It does not, because priorities reorder execution; you have to collect results through Promise.all or reach for p-map
Setup reality
npm install p-queue, import PQueue from 'p-queue', done. Two small dependencies (eventemitter3 and p-timeout), no build step, no peer dependencies. The friction is ESM: v7 dropped the CommonJS build, so a require() codebase either converts or pins the old major, and v9 additionally demands Node.js 20. The second trap is awaiting add() at the point you enqueue, which serialises everything and quietly defeats the queue; you want to collect the promises and await them later. Jest users need real timers or the queue's interval logic stalls, which the README covers in its FAQ.
Patterns
Cap how many tasks run at oncebasic-concurrency-queue
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 4});
const results = await Promise.all(
urls.map(url => queue.add(() => fetch(url).then(r => r.json())))
);Collect the promises and await them together. Writing `await queue.add(...)` inside the loop waits for each task to finish before enqueuing the next, which runs everything one at a time.
Stay under a requests-per-second limitrate-limit-per-interval
import PQueue from 'p-queue';
// at most 10 starts per second, at most 3 in flight
const queue = new PQueue({
intervalCap: 10,
interval: 1000,
concurrency: 3,
});
await Promise.all(ids.map(id => queue.add(() => api.get(id))));concurrency and intervalCap are independent constraints: one limits simultaneous execution, the other limits starts per window. Default mode resets the counter at fixed boundaries, so two windows can burst back to back.
Sliding window when the API is unforgivingstrict-rate-limiting
import PQueue from 'p-queue';
const queue = new PQueue({
intervalCap: 2,
interval: 1000,
strict: true,
});strict mode tracks individual start timestamps so no rolling 1000ms window ever contains more than 2 starts. It costs more memory and CPU than the default fixed window, and carryoverIntervalCount has no effect when it is on.
Jump the line for user-facing worktask-priority
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 1});
queue.add(() => reindexEverything(), {priority: 0});
queue.add(() => renderThumbnail(id), {priority: 10, id: `thumb-${id}`});
// later, bump something already waiting
queue.setPriority(`thumb-${id}`, 100);Higher priority runs first, and setPriority only affects tasks still waiting. It needs a defined concurrency limit to take effect, and you must pass your own id to be able to target a task later.
Cancel with AbortSignal, not clear()cancel-queued-tasks
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 2});
const controller = new AbortController();
const jobs = files.map(file =>
queue.add(({signal}) => upload(file, {signal}), {signal: controller.signal})
.catch(error => { if (error.name !== 'AbortError') throw error; })
);
process.on('SIGTERM', () => controller.abort());
await Promise.all(jobs);Aborting removes queued tasks and rejects their add() promise cleanly. queue.clear() also drops them, but their promises never settle, so an awaiting caller hangs. Already-running tasks must read the signal themselves.
Stop a hung task from holding a slotper-task-timeout
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 5, timeout: 30_000});
// override for one slow task
await queue.add(() => generateReport(), {timeout: 120_000});The clock starts when the task is dequeued and begins running, not while it waits. On expiry the add() promise rejects with TimeoutError, but the underlying work is not killed unless your function handles it.
Pause, then wait for in-flight workpause-and-drain
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 4});
queue.pause();
await queue.onPendingZero(); // running tasks finished; queue still holds items
await migrateSchema();
queue.start();onPendingZero waits only for running tasks. onIdle waits for the queue to empty and all of it to finish; onEmpty resolves as soon as nothing is waiting, even if tasks are still running.
Stop a fast producer from filling memorybackpressure-producer
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 8});
for await (const row of readHugeCsv('events.csv')) {
await queue.onSizeLessThan(500);
queue.add(() => indexRow(row)).catch(logFailure);
}
await queue.onIdle();onSizeLessThan counts only waiting items, so up to `concurrency` more can be running on top of the limit. Without a catch on each add(), a single failure surfaces as an unhandled rejection.
Get results back in input orderresults-in-order
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 4});
const results = await Promise.all(
tasks.map(task => queue.add(task))
);The queue executes in priority order and completes in whatever order things finish. Promise.all restores input order because the array order is preserved, not the settle order.
Track progress with eventsprogress-events
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 3});
let done = 0;
queue.on('completed', () => {
console.log(`${++done} done, ${queue.pending} running, ${queue.size} waiting`);
});
queue.on('error', error => console.error('task failed:', error));
items.forEach(item => queue.add(() => handle(item)).catch(() => {}));
await queue.onIdle();size counts waiting items and pending counts running ones. The error event does not replace handling the add() promise; without the catch you still get an unhandled rejection.
Abandon the batch on the first failurefail-fast-on-error
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 4});
items.forEach(item => queue.add(() => process(item)).catch(() => {}));
try {
await Promise.race([queue.onError(), queue.onIdle()]);
} catch (error) {
queue.pause();
throw error;
}onError rejects on the first task failure while onIdle resolves on clean completion, so the race gives fail-fast behaviour. You still have to pause the queue yourself; nothing stops automatically.
Find out which task is stuckdebug-stuck-queue
import PQueue from 'p-queue';
const queue = new PQueue({concurrency: 2, timeout: 30_000});
queue.add(() => syncAccount(id), {id: `account-${id}`});
setInterval(() => {
if (queue.isSaturated) {
console.warn(queue.runningTasks);
}
}, 60_000).unref();A queue that stops processing almost always has tasks hanging forever and eating the concurrency slots. runningTasks reports each task id, start time, and remaining timeout, which is why giving tasks explicit ids pays off.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| p-limit | npm | You only need a concurrency cap and none of the queue features, at a fraction of the size. |
| p-throttle | npm | The only constraint is calls per time window and you do not care how many run at once. |
| bullmq | npm | Jobs must survive process restarts, spread across machines, or retry with a dead-letter queue. |
| fastq | npm | You want a tiny callback-or-promise worker queue with no rate limiting and CommonJS support. |