p-limit
p-limit does exactly one thing: it caps how many promise-returning functions run at the same time. You call pLimit(5) to get a limit function, wrap each task in it, and no more than five tasks execute concurrently while the rest wait in an internal queue. That is the entire mental model. Recent versions add conveniences on top: limit.map for processing an array, a limitFunction helper that wraps a single function permanently, live activeCount and pendingCount properties, an adjustable concurrency setter, and clearQueue for teardown. It is under 1 KB gzipped with a single dependency and sits inside a huge share of the npm dependency graph.
The default answer to "run these async tasks but only n at a time" and correctly boring at that job; the size cost is negligible. Reach for p-queue or p-throttle only when your real requirement is queue management or time-based rate limiting.
Use it if
- You are fanning out hundreds of fetches, file reads, or DB calls and need to stop the process from opening them all at once and tripping rate limits or file descriptor caps
- You want the smallest possible dependency for the job: one tiny queue dependency, no classes to configure, works in Node.js and browsers
- You need runtime introspection or control: activeCount and pendingCount for progress display, or changing limit.concurrency on the fly based on backpressure
- Several different call sites must share one global concurrency budget, which is exactly what passing a single limit function around gives you
- You actually need requests-per-second limiting: p-limit caps simultaneous executions, not rate; a fast API that answers in 50ms will still see far more than n requests per second, so use p-throttle for time-based limits
- You need queue features like pausing, priorities, timeouts per task, or events: that is p-queue, a bigger tool from the same author
- Your project is CommonJS on an older runtime: v4 and later are ESM-only and v7 requires Node.js 20+, so require() users on old Node are pinned to the years-old p-limit@3
- Your main use is mapping one array with concurrency and you want stop-on-error semantics and result ordering options: p-map is more ergonomic than hand-wiring limit calls, though limit.map now covers the simple case
Setup reality
npm install p-limit and you are done: no config, no peer dependencies, one transitive dependency (yocto-queue). The gotchas are usage, not setup. The package is ESM-only, so CommonJS codebases on Node.js older than the require(esm) era need p-limit@3 and miss newer features. The classic footgun is calling the same limit function inside a task that is already limited by it, which deadlocks because the inner call waits for a slot the outer call is occupying; the README warns about this explicitly. Also note clearQueue leaves already-awaited promises pending forever unless you enable rejectOnClear.
Patterns
Run many tasks, at most n at a timebasic-concurrency-limit
import pLimit from 'p-limit';
const limit = pLimit(5);
const results = await Promise.all(
urls.map(url => limit(() => fetch(url).then(r => r.json())))
);Wrap the function, do not call it: limit(() => fetch(url)) queues it, while limit(fetch(url)) would start every fetch immediately and defeat the limiter.
Map an array with limit.mapmap-iterable
import pLimit from 'p-limit';
const limit = pLimit(3);
const results = await limit.map(userIds, async (id, index) => {
return fetchUser(id);
});Equivalent to wiring limit around each element yourself; the mapper receives value and index. For stop-on-error control or async iterables, p-map is the bigger tool.
Permanently wrap one functionlimit-single-function
import {limitFunction} from 'p-limit';
const fetchPage = limitFunction(
async (url) => (await fetch(url)).text(),
{concurrency: 2},
);
await Promise.all(pages.map(fetchPage));limitFunction is a named export and suits module-level helpers: every caller anywhere shares the same concurrency budget without passing a limit around.
Pass arguments without creating closurespass-arguments
import pLimit from 'p-limit';
const limit = pLimit(4);
const tasks = files.map(file => limit(processFile, file));
await Promise.all(tasks);limit(fn, ...args) forwards arguments to fn. The README calls this a micro-optimization you only need when queueing very large numbers of tasks.
Watch active and pending countsprogress-introspection
import pLimit from 'p-limit';
const limit = pLimit(8);
const jobs = items.map(item => limit(() => handle(item)));
const timer = setInterval(() => {
console.log(`running: ${limit.activeCount}, queued: ${limit.pendingCount}`);
}, 1000);
await Promise.all(jobs);
clearInterval(timer);activeCount is tasks currently running; pendingCount is tasks whose function has not been called yet. Cheap enough to poll for progress bars.
Change the limit while runningadjust-concurrency-runtime
import pLimit from 'p-limit';
const limit = pLimit(2);
// later, when the API stops returning 429s:
limit.concurrency = 10;limit.concurrency is a live getter and setter. Raising it starts queued tasks immediately; lowering it only takes effect as running tasks finish.
Tear down and reject what never ranclear-queue-teardown
import pLimit from 'p-limit';
const limit = pLimit({concurrency: 2, rejectOnClear: true});
const jobs = items.map(item => limit(() => sync(item)));
process.on('SIGINT', () => {
limit.clearQueue(); // pending jobs reject with AbortError
});
const results = await Promise.allSettled(jobs);Without rejectOnClear, cleared tasks leave their promises pending forever and an awaiting Promise.all simply never settles. clearQueue never cancels tasks already running.
Keep going when some tasks failhandle-failures
import pLimit from 'p-limit';
const limit = pLimit(5);
const outcomes = await Promise.allSettled(
urls.map(url => limit(() => fetch(url)))
);
const failed = outcomes.filter(o => o.status === 'rejected');p-limit does not stop the queue on rejection; each promise fails individually. Promise.all rejects on the first failure while the rest keep running, so allSettled is usually what you want.
Use separate limiters for nested workavoid-nested-deadlock
import pLimit from 'p-limit';
const outerLimit = pLimit(2);
const innerLimit = pLimit(5);
await Promise.all(repos.map(repo => outerLimit(async () => {
const files = await listFiles(repo);
return Promise.all(
files.map(f => innerLimit(() => download(f)))
);
})));Reusing one limit function inside a task it already limits can deadlock: inner calls wait for slots the outer calls hold. The README warns about exactly this.
Share one concurrency budget across modulesshared-global-budget
// http-limit.js
import pLimit from 'p-limit';
export const httpLimit = pLimit(10);
// anywhere else
import {httpLimit} from './http-limit.js';
await httpLimit(() => fetch('https://api.example.com/data'));Exporting a single limit instance is the simplest way to cap total outbound connections process-wide, no matter how many modules make requests.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| p-queue | npm | You need a real queue: priorities, pause and resume, per-task timeouts, and events. |
| p-map | npm | You are mapping over one iterable with a concurrency cap and want error and ordering behavior handled for you. |
| p-throttle | npm | The constraint is calls per time window (API rate limits), not simultaneous executions. |