The p-limit npm package throttles asynchronous work so that only a fixed number of promise-returning functions execute at once — and that single capability is one of the cheapest reliability and security controls you can add to a Node.js service. Unbounded concurrency is a self-inflicted denial of service waiting for a large enough input: ten thousand files to hash, ten thousand URLs to fetch, ten thousand rows to write, all launched in one Promise.all. This guide covers how p-limit works, the patterns that use it well, and the failure modes — some of them security-relevant — that a concurrency cap prevents.
The problem: Promise.all is an accelerator with no brake
This code looks idiomatic and is a production incident at scale:
// launches EVERY request immediately
const results = await Promise.all(urls.map((u) => fetch(u)));
Promise.all does not run tasks; it waits for tasks that are already running. The .map() started all of them in the same tick. With 50 URLs, fine. With 50,000: tens of thousands of concurrent sockets, file descriptors exhausted, memory ballooning with in-flight response buffers, the upstream API rate-limiting or null-routing you, and your own event loop starved. If the list length is influenced by user input — every uploaded manifest entry, every row in an import — an outsider controls your fan-out, which upgrades a performance bug into an abuse vector.
What p-limit does
p-limit (by Sindre Sorhus, with a single tiny dependency, yocto-queue) gives you a limiter function; wrap each task in it, and at most n run concurrently while the rest queue:
import pLimit from "p-limit";
const limit = pLimit(8);
const results = await Promise.all(
urls.map((u) => limit(() => fetch(u)))
);
Note the shape: you pass limit a function that starts the work, not a promise. limit(fetch(u)) would defeat the purpose, because calling fetch(u) starts the request before the limiter ever sees it. This is the number one p-limit bug in code review, and it fails silently — everything still resolves, just without any limiting.
The limiter also exposes useful introspection — limit.activeCount (running now) and limit.pendingCount (queued) — which make good gauge metrics if you export them to your monitoring.
Choosing a sensible limit
There is no universal number, but there are good defaults per resource class:
- Outbound HTTP to one host: 4–10. Respect the target's documented rate limits; being a polite client is also how you avoid your crawler IP getting banned.
- Filesystem and CPU-ish work (hashing, compression): around the core count —
os.availableParallelism()is the right starting point. - Database writes: at or below your connection pool size, or you are just moving the queue into the pool.
Make the limit a config value, not a literal. The correct number changes with instance size and downstream capacity, and you want to turn it down during an incident without a deploy.
Patterns beyond the basic map
Nested limits for multi-resource pipelines. A scanner that downloads then parses wants separate caps, because network and CPU saturate independently:
const netLimit = pLimit(10);
const cpuLimit = pLimit(4);
async function process(pkg) {
const tarball = await netLimit(() => download(pkg));
return cpuLimit(() => extractAndScan(tarball));
}
await Promise.all(packages.map(process));
Fail-fast versus collect-all. Promise.all rejects on the first failure but already-queued tasks still run down. For batch jobs where one bad item should not sink the run, pair the limiter with Promise.allSettled and triage the rejects afterward.
Timeouts inside the limited task. A limiter plus a hung upstream equals a stalled queue: if all 8 slots are occupied by requests that never resolve, throughput hits zero while looking "limited." Give every slot a deadline (AbortSignal.timeout(ms) on fetch) so slots recycle. Limit, timeout, and retry-with-backoff are one pattern, not three.
Why this belongs in a security review
Resource-exhaustion resilience is a security property (it is availability, the A in the triad), and concurrency caps show up in real findings:
- Input-driven fan-out. Anywhere request-controlled data determines how many operations you launch — webhook fan-outs, import processors, per-dependency lookups in a manifest scan — an attacker sizes your workload. A cap turns "attacker chooses your concurrency" into "attacker waits in a queue."
- Amplification against others. An unbounded fetcher pointed at attacker-supplied URLs makes your infrastructure a DDoS reflector. Caps plus per-host budgets keep you from being someone else's incident.
- Graceful degradation under scanning. Aggressive crawlers and DAST scans are the friendly version of a flood; services whose internal fan-outs are bounded degrade linearly instead of falling over, which is exactly what you want your own scanner runs to verify before a hostile party does.
We have hit this in our own pipeline work: a vulnerability-scan stage that fanned out one subprocess per dependency behaved perfectly in test repos and then attempted several thousand concurrent scans on the first monorepo it met. A queue with a hard cap is the difference between a slow batch and an OOM-killed worker.
Package health and alternatives
p-limit is about as low-risk as a dependency gets: single-purpose, a few dozen lines, one micro-dependency, enormously depended-upon, actively maintained, and free of recorded CVEs. Recent major versions are ESM-only, which is the only migration friction most teams hit. Still apply baseline hygiene — lockfile pinning and SCA monitoring, because popularity makes any package a supply chain target regardless of size, and a tool such as Safeguard will flag an anomalous publish on a dependency this deep in your tree faster than any human notices.
Alternatives worth knowing: p-queue (same author) adds priorities, rate-per-interval, and pause/resume — the upgrade path when a plain cap stops being enough; p-map bundles map-with-concurrency into one call; and on current Node.js you can get a dependency-free version of the pattern with a small semaphore class in about fifteen lines, a legitimate choice for zero-dependency CLIs. For everything else, the ecosystem-standard package is easier to review than a bespoke semaphore is to get right.
FAQ
What does the p-limit npm package do?
It creates a limiter that allows at most n promise-returning functions to run concurrently, queueing the rest. You wrap each task — limit(() => doWork()) — and use Promise.all as usual; p-limit controls when each task actually starts.
Why is my p-limit not limiting anything?
Almost always because the work starts before the limiter sees it: limit(fetch(url)) calls fetch immediately. Pass a function instead: limit(() => fetch(url)). The limiter must be the thing that invokes your task.
Is p-limit safe to depend on?
Yes. It has no known CVEs, one tiny dependency (yocto-queue), a minimal code surface, and heavy ecosystem usage with active maintenance. Standard supply chain hygiene — lockfiles and SCA alerts — covers the residual risk.
What is a reasonable concurrency limit?
Depends on the bottleneck: 4–10 for outbound HTTP per host, roughly the CPU core count for compute-bound work, and no more than your pool size for database operations. Make it configurable and watch activeCount/pendingCount in production to tune it.