mrkeyoor.com_
Wed 05 Aug 19:56 UTC
npmUtilsupdated 05 Aug 2026

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.

Verdict

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.

API stability5/5The core pLimit(n) then limit(fn) shape has not changed in years; major version bumps are almost entirely Node.js requirement and ESM packaging changes, with features like limit.map added without breaking anything.
Docs4/5The README documents every export and option and honestly flags the deadlock and clearQueue pitfalls, plus a recipes file; there is no docs site, but a package this small does not need one.
Maintenance5/5Actively maintained by sindresorhus, pushed July 2026, and the tracker shows zero open issues and PRs, which for a package this widely used means problems get resolved fast.
Ecosystem5/5About 317M weekly downloads as a transitive dependency of a large share of build tooling, and it plugs into the wider p-* family (p-map, p-queue, p-retry) when needs grow.

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
Skip it if

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

PackageRegistryPick it when
p-queuenpmYou need a real queue: priorities, pause and resume, per-task timeouts, and events.
p-mapnpmYou are mapping over one iterable with a concurrency cap and want error and ordering behavior handled for you.
p-throttlenpmThe constraint is calls per time window (API rate limits), not simultaneous executions.