TechEarl

Limit Concurrent Promises in JavaScript (Promise Pool Pattern)

Run an array of async tasks N at a time instead of all at once. A 15-line, dependency-free promise pool (teMapLimit), the Promise.all failure mode it fixes, plus p-limit and p-map for production.

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
Limit concurrent promises in JavaScript with a dependency-free promise pool: run N tasks at a time to avoid 429s, socket exhaustion, and rate limits, or reach for p-limit and p-map.

To run a large array of async tasks without firing them all at once, pull each task from a shared queue with a fixed number of workers. That is a promise pool: instead of Promise.all(items.map(fn)), which starts every task in the same tick, you run at most N tasks concurrently and start the next one only when a slot frees up. Here is the whole pattern, dependency-free:

javascript
async function teMapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let cursor = 0;
  async function teWorker() {
    while (cursor < items.length) {
      const index = cursor++;          // claim a slot, then release the loop
      results[index] = await fn(items[index], index);
    }
  }
  const workers = Array.from({ length: Math.min(limit, items.length) }, teWorker);
  await Promise.all(workers);
  return results;
}

Call it like Promise.all, but with a cap:

javascript
const urls = [/* 10,000 of them */];
const bodies = await teMapLimit(urls, 5, async (url) => {
  const res = await fetch(url);
  return res.text();
});

Five fetches are in flight at any moment, never more. The rest of this page is why that matters, how the pool actually works, when to reach for p-limit or p-map instead, and how to wire in cancellation.

Why you cannot just use Promise.all

Promise.all does not run things "in parallel" in any throttled sense. It takes an array of promises that are already pending and waits for all of them. The work starts the moment you create the promise, so items.map(fn) calls fn on every element synchronously, in one tick, before Promise.all even sees the array. Ten thousand items means ten thousand fetch calls launched at once.

That fans out into real failures:

  • HTTP 429 (Too Many Requests). Most APIs rate-limit per client. Blast 10,000 requests in a burst and you get throttled or temporarily banned, and now you have to retry the survivors.
  • Socket and file-descriptor exhaustion. Each open connection is a socket. In Node.js you will hit EMFILE (too many open files) or run the event loop out of descriptors; in the browser, requests just queue behind the per-host connection cap and stall.
  • Memory. Ten thousand in-flight response objects and their buffers sit in memory at once instead of being processed and freed in waves.
  • You hammer the server you depend on. Even when there is no formal rate limit, a synchronized burst is the kind of traffic spike that looks like an attack.

A pool of, say, 5 or 10 keeps throughput high while staying inside whatever the downstream can tolerate. You almost never want unbounded concurrency against a network resource.

How the pool works

The trick is that the workers share one mutable cursor instead of being handed fixed slices.

  1. teMapLimit spawns limit worker functions (or fewer, if there are fewer items than the limit). Each worker is an async function, so calling it returns a promise immediately.
  2. Every worker loops: it reads the shared cursor, increments it (cursor++ returns the old value and bumps it in the same expression), and processes that index. Because JavaScript is single-threaded, the read-and-increment is atomic. No two workers ever claim the same index.
  3. When a worker awaits fn(...), it yields the event loop, so the other workers get to claim their own indices and run. That is what produces the overlap: up to limit tasks are suspended on their await at once.
  4. When the queue is empty (cursor >= items.length), each worker's loop exits and its promise resolves. Promise.all(workers) resolves once they all do, and the ordered results array is returned.

Results land at results[index], not in completion order, so the output array matches the input order exactly, the same contract Promise.all gives you. A self-draining queue like this naturally balances uneven workloads: a worker that finishes a fast task immediately grabs the next index rather than idling while a sibling is stuck on a slow one. Fixed slicing (items.length / limit chunks per worker) does not get that for free.

One caveat to match Promise.all's behavior: the version above rejects on the first error (the await throws, the worker's promise rejects, Promise.all rejects). If you want every task to run to completion and collect both successes and failures, swap the inner Promise.all(workers) for Promise.allSettled, or have fn catch its own errors. See Promise.all vs allSettled vs race vs any for which combinator fits.

Adding cancellation with AbortSignal

If the caller navigates away or a timeout fires, you want the in-flight tasks to stop and the queued ones to never start. Thread an AbortSignal through and check it at the top of each loop:

javascript
async function teMapLimit(items, limit, fn, { signal } = {}) {
  const results = new Array(items.length);
  let cursor = 0;
  async function teWorker() {
    while (cursor < items.length) {
      signal?.throwIfAborted();         // stop claiming new work once aborted
      const index = cursor++;
      results[index] = await fn(items[index], index, signal);
    }
  }
  const workers = Array.from({ length: Math.min(limit, items.length) }, teWorker);
  await Promise.all(workers);
  return results;
}

signal.throwIfAborted() throws an AbortError the moment the signal is aborted, so no new tasks get claimed. Pass the same signal down to fn so the active fetch calls abort too:

javascript
const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]);
await teMapLimit(urls, 5, (url, _i, sig) => fetch(url, { signal: sig }), { signal });

AbortSignal.timeout and AbortSignal.any are both available in modern browsers and in Node.js (Node 17.3+ and 20.3+ respectively). For the full pattern of cancelling and timing out individual requests, see add a timeout to fetch with AbortController.

When to use a library instead

The hand-rolled pool is fine, and it is genuinely about fifteen lines. But for production code you probably want a maintained package that has handled the edge cases (zero-length input, mapper throwing synchronously, abort, backpressure):

  • p-limit is the minimal primitive. You create a limit(concurrency) function and wrap each call: limit(() => fetch(url)). It returns a promise per call, so you compose it with your own Promise.all. Reach for it when you want concurrency control but keep your own orchestration.
  • p-map is the direct teMapLimit analog: pMap(items, mapper, { concurrency: 5 }). It also takes stopOnError: false (collect every result and reject with an AggregateError of all failures, like allSettled) and a signal option for AbortController. This is the one I default to.
  • @ricokahler/pool is another small, zero-dependency option framed as "like Promise.all but you can limit the concurrency," and the one David Walsh reached for in his widely-cited post on the pattern.

All three solve the same problem the pool above does. The point of showing the bare version is that you understand exactly what they are doing, and you can drop in the fifteen lines when adding a dependency is not worth it.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsJavaScriptpromisesconcurrencypromise poolrate limitp-limitp-mapAbortSignalNode.js

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts