Run async tasks with a concurrency limit
A small mapLimit helper that runs an async function over many items with at most N in flight, keeps result order, stops on the first error and supports cancellation.
Drafted with AI from official documentation. Scripts were run in a test environment and repository links verified. Awaiting a human check. Did it work for you? Last edited Outdated?
What this changes
Read-only: the demo only runs timers and prints the results; nothing on your system is changed. Used with the fetch example, it sends GET requests to the URLs you list.
How to undo it
Nothing to undo.
Promise.all(items.map(task)) starts every task at once. With 5,000 URLs or files, that can hit rate limits, exhaust sockets or file handles, or use too much memory. A concurrency limit runs at most N tasks at a time and starts the next one as soon as a slot frees up.
Design
- Worker pool. Instead of batching (run 5, wait for all 5, run the next 5), it starts N workers that each pull the next index from a shared counter. A slow task never blocks the other slots.
- Ordered results. Each result is stored at its original index, so the output lines up with the input like
Promise.all. - Fail fast. The first error rejects the whole call, stops workers from taking new items, and aborts the shared
AbortSignalso tasks still running can stop early. - Cancellation. An optional outer
signal, for exampleAbortSignal.timeout(30_000), aborts everything.
The shared counter is safe without locks because JavaScript runs one piece of code at a time: next++ can never be interrupted halfway by another worker.
The script
Save as pool.mjs. Node 20+ or a modern browser. Running node pool.mjs executes the demo at the bottom.
import { fileURLToPath } from "node:url";
export async function mapLimit(items, limit, task, { signal } = {}) {
if (!Number.isInteger(limit) || limit < 1) throw new RangeError("limit must be a positive integer");
signal?.throwIfAborted();
const list = Array.from(items);
const results = new Array(list.length);
const controller = new AbortController();
const onOuterAbort = () => controller.abort(signal.reason);
signal?.addEventListener("abort", onOuterAbort, { once: true });
let next = 0;
async function worker() {
while (next < list.length) {
controller.signal.throwIfAborted();
const index = next++;
try {
results[index] = await task(list[index], index, controller.signal);
} catch (err) {
controller.abort(err);
throw err;
}
}
}
try {
const workers = Array.from({ length: Math.min(limit, list.length) }, worker);
await Promise.all(workers);
return results;
} finally {
signal?.removeEventListener("abort", onOuterAbort);
}
}
if (import.meta.main ?? process.argv[1] === fileURLToPath(import.meta.url)) {
const sleep = (ms, signal) =>
new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal.addEventListener("abort", () => { clearTimeout(t); reject(signal.reason); }, { once: true });
});
let running = 0;
let peak = 0;
const started = Date.now();
const out = await mapLimit([300, 100, 200, 100, 50, 250], 3, async (ms, i, signal) => {
running++;
peak = Math.max(peak, running);
try {
await sleep(ms, signal);
return `job${i}:${ms}ms`;
} finally {
running--;
}
});
console.log(out, `peak=${peak}`, `took≈${Math.round((Date.now() - started) / 50) * 50}ms`);
try {
await mapLimit([100, 20, 500, 500], 2, async (ms, i, signal) => {
await sleep(ms, signal);
if (i === 1) throw new Error(`job ${i} failed`);
return ms;
});
} catch (err) {
console.log("rejected with:", err.message);
}
try {
await mapLimit([1000, 1000], 2, (ms, i, signal) => sleep(ms, signal), { signal: AbortSignal.timeout(100) });
} catch (err) {
console.log("outer signal:", err.name);
}
}
Real-world usage
import { mapLimit } from "./pool.mjs";
const urls = ["https://example.com/a", "https://example.com/b" /* ... */];
const pages = await mapLimit(urls, 4, async (url, i, signal) => {
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
return res.text();
});
Pass the signal argument through to fetch, stream reads or your own code. Tasks that ignore it keep running after a failure; the helper simply stops starting new ones.
Variations
- Collect every result, even failures. Wrap the task:
(x) => task(x).then((value) => ({ ok: true, value }), (error) => ({ ok: false, error })). Then nothing throws and you get anallSettled-style report. - Rate limits per second are a different problem from concurrency. Combine this with a delay inside the task, or use a token-bucket limiter.
- Streams of unknown length. For async iterables, Node’s readable streams offer
stream.map(fn, { concurrency }), andIteratorhelpers are arriving in newer runtimes. - Libraries.
p-limitandp-mapfrom npm implement the same idea with more options and long production use.
Choosing N
There is no universal number. For HTTP calls to one host, start low (4 to 10) and respect the API’s documented limits. For local disk reads, a few dozen is usually fine. For CPU-heavy work, a promise pool does not help at all, because JavaScript still runs on one thread; use worker_threads instead. Measure with your real workload and adjust.
Did this work for you?
No votes yet. Be the first.
If it did not work, the report form below tells the editors what went wrong.
See also
- 0018 Promises and async/await explained A promise is a placeholder for a value that arrives later. async/await lets you write code that waits for promises as if… Intermediate · Explains an idea · Any system Article Async code 15 Oct 2026
- 0019 fetch with timeout, retries and proper error handling A reusable fetchJson helper for Node 20+ and browsers that adds a timeout, retries with backoff for temporary failures, and clear errors… Intermediate · Step by step · Any system Script Async code · Making requests 15 Oct 2026
- 0085 fetch with timeout, retries, and exponential backoff A drop-in fetch wrapper that times out slow requests, retries network errors and 429/5xx responses with jittered exponential backoff, and honors Retry-After. Advanced · Step by step · Any system, Browser Script Async code · Making requests 15 Oct 2026
- 0087 CORS explained: why the browser blocks your request CORS is the browser rule that decides whether JavaScript on one origin may read a response from another. The server opts in… Intermediate · Explains an idea · Browser Article Async code · Making requests 15 Oct 2026
Repos in the wild
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Dominik +4,985 chars