Taming Node.js Concurrency: Avoiding API Meltdowns with p-map and Bottleneck
Unbounded Promise.all can melt APIs. Combine p-map and Bottleneck in Node.js to cap concurrency, respect rate limiting and keep downstream systems standing.
This article is published in English.
Sources: Stop Melting Your APIs: Smart Concurrency Control in Node.js with p-map & Bottleneck
Most Node.js applications don't collapse because the logic is wrong. They collapse because they succeed too aggressively, all at the same moment.
Picture a scenario: a team ships a feature that fires off roughly 10,000 API calls within a few seconds. On a local machine, everything runs smoothly and quickly, and the results look impressive.
Once that same code reaches production, the picture changes:
- 429 errors show up everywhere
- The third-party API starts rejecting requests
- Memory consumption spikes
- Retry logic makes the situation worse instead of better
The real issue was never the external API itself. It was concurrency running without any limits.
The Trap Hiding Inside Promise.all
Many developers reach for this pattern first:
await Promise.all(urls.map(fetchData));
It reads cleanly and appears performant.
But look closer at what it actually triggers:
- Every request fires simultaneously
- There is no ceiling on how many run together
- There's no backpressure mechanism
- Rate limits set by the API are completely ignored
In practice, this pattern behaves like a generator of sudden traffic surges.
Promise.alldoesn't give you concurrency control. It gives you a concurrency explosion.
Step One: Cap Concurrency Using p-map
If your goal is to restrict how many operations execute in parallel, p-map is the tool built for that job.
import pMap from 'p-map';
const result = await pMap(
urls,
async (url) => {
const res = await fetch(url);
return res.json();
},
{ concurrency: 5 }
);
What This Fixes
- Just five requests run in parallel at any given moment
- The rest sit in a queue until a slot opens up
- CPU and memory usage remain steady
The Underlying Idea
Concurrency isn't really about squeezing out more speed. It's about keeping pressure on your system under control.
Why That Alone Isn't Sufficient
Setting concurrency: 5 doesn't guarantee you'll avoid rate limits.
APIs don't only track how many requests arrive together, they also track how many arrive within a given time window.
That means you need to manage time, not just the number of parallel calls.
Step Two: Enforce Rate Limits with Bottleneck
import Bottleneck from "bottleneck";
const limiter = new Bottleneck({
maxConcurrent: 5,
minTime: 200 // ~5 requests per second
});
const fetchWithLimit = limiter.wrap(async (url) => {
const res = await fetch(url);
return res.json();
});
const results = await Promise.all(urls.map(fetchWithLimit));
What This Achieves
- Keeps concurrency bounded
- Distributes requests across time
- Stops bursts before they happen
Instead of hammering the API all at once, you're now feeding it requests at a steady, predictable pace.
p-map Versus Bottleneck
Each tool solves a different problem:
- p-map handles the case where too many promises run at the same instant
- Bottleneck handles the case where the API rate limit triggers 429 errors
- When you need both protections, combine the two libraries
A Pattern Worth Using in Production
import pMap from 'p-map';
import Bottleneck from 'bottleneck';
const limiter = new Bottleneck({
minTime: 200
});
const task = limiter.wrap(async (url) => {
const res = await fetch(url);
return res.json();
});
await pMap(urls, task, { concurrency: 5 });
This combination delivers:
- Steady, bounded concurrency
- Protection against hitting rate limits
- A load profile you can predict in advance
Dealing with Failures: Retries with Backoff
Even a carefully throttled system will occasionally hit failures. What separates a resilient system from a fragile one is how it responds to those failures.
async function fetchWithRetry(url, retries = 3) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Failed');
return res.json();
} catch (err) {
if (retries === 0) throw err;
// exponential backoff
const delay = 2 ** (3 - retries) * 100;
await new Promise(r => setTimeout(r, delay));
return fetchWithRetry(url, retries - 1);
}
}
Why Backoff Matters Here
- Retrying without any delay tends to create retry storms
- Retrying with backoff allows the system to recover gracefully instead
The Trade-off Between Throughput and Latency
This is the point where genuine system design decisions come into play.
- Lower concurrency gives you a safer but slower pipeline
- Higher concurrency speeds things up but introduces more risk
For instance:
- Setting concurrency to 2 keeps things stable, though slow
- Setting concurrency to 20 speeds execution up but may push you past rate limits
There is no universal ideal setting. You have to tune the value according to what your system and the target API can actually handle.
Observability: The Step Most Teams Skip
Don't just deploy this setup and walk away from it in production.
Keep an eye on:
- how large the queue grows
- the average time requests spend waiting
- the ratio of successes to failures
Without visibility into what your system is actually doing, tuning it correctly becomes guesswork.
Where Do Queues Like BullMQ or Kafka Fit In?
This is a fair question to raise.
- Message queues offer durable, asynchronous processing
- p-map and Bottleneck offer in-process control within a single service
Reach for a queue when:
- jobs need to survive a crash or restart
- the workload is large-scale or spread across multiple services
Reach for the p-map and Bottleneck approach when:
- you need fast, controlled execution that stays inside one service
Measured Impact After Applying This
After introducing both concurrency limits and rate limiting together, one production system saw:
- 429 error rates fall from roughly 18% down to under 1%
- Noticeably improved stability when talking to the external API
- An end to retry storms altogether
The application logic stayed the same. What changed was the discipline applied around execution.
Closing Thought
Writing asynchronous code is a baseline skill for Node.js developers. Knowing how to govern the speed at which that code executes is what distinguishes strong backend engineers.
If your service communicates with:
- external APIs
- databases
- other microservices
then concurrency control stops being a nice-to-have and becomes a core part of your architecture.
Wrapping Up
If your production code still relies on Promise.all without any limits, you're only one traffic spike away from an outage.
A practical starting point looks like this:
- Introduce
p-mapto bound concurrency - Introduce
Bottleneckto enforce rate limits - Add retry logic to build in resilience
That's the path from code that merely works to code that actually scales.