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:
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:
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.
teMapLimitspawnslimitworker functions (or fewer, if there are fewer items than the limit). Each worker is an async function, so calling it returns a promise immediately.- 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. - When a worker
awaitsfn(...), it yields the event loop, so the other workers get to claim their own indices and run. That is what produces the overlap: up tolimittasks are suspended on theirawaitat once. - 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 orderedresultsarray 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:
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:
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 ownPromise.all. Reach for it when you want concurrency control but keep your own orchestration. - p-map is the direct
teMapLimitanalog:pMap(items, mapper, { concurrency: 5 }). It also takesstopOnError: false(collect every result and reject with anAggregateErrorof all failures, likeallSettled) and asignaloption forAbortController. 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
Not in any throttled sense. Promise.all waits for promises that are already pending; the work starts the instant each promise is created. So Promise.all(items.map(fn)) launches every task in the same tick. To cap how many run at once you need a pool that starts tasks lazily, which is what teMapLimit does.
It depends on what you are hitting. For a rate-limited API, match the documented limit (often 5 to 20 requests in flight). For local disk or CPU work, the core count is a reasonable starting point. Start conservative, watch for 429s and errors, and raise it only if the downstream tolerates it. Unbounded is almost never right against a network resource.
Write each result to results[index] using the index the worker claimed, not by pushing in completion order. The pool above does this, so the returned array lines up with the input array exactly, the same as Promise.all. p-map preserves order the same way.
In the version shown, the failing await throws, that worker's promise rejects, and Promise.all rejects, so the whole call rejects on the first error (matching Promise.all). To run everything to completion and collect both successes and failures, use Promise.allSettled internally, have your mapper catch its own errors, or use p-map with stopOnError: false.
Both are reasonable. The fifteen-line pool is enough when you want zero dependencies and understand the mechanics. For production, p-map and p-limit are battle-tested and handle the edge cases (empty input, synchronous throws, abort) you would otherwise have to cover yourself.
See also
- JavaScript Promises: a complete guide: how promises and async/await actually work, the foundation this pattern builds on.
- Promise.all vs allSettled vs race vs any: which combinator to wrap your workers in, and why a failed task short-circuits the rest.
- Add a timeout to fetch with AbortController: the cancellation primitive used above, including
AbortSignal.timeoutandAbortSignal.any.
Sources
Authoritative references this article was fact-checked against.
- Promise.all() — MDNdeveloper.mozilla.org
- p-limit README (official)github.com
- p-map README (official)github.com
- AbortSignal — MDNdeveloper.mozilla.org





