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
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





