Home / Articles

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.

Publisher: REACTAPP.TOP5 min read1240 words

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.all doesn'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-map to bound concurrency
  • Introduce Bottleneck to enforce rate limits
  • Add retry logic to build in resilience

That's the path from code that merely works to code that actually scales.