Concept illustration: three occupied slots, with the remaining work waiting.
A script fetches a handful of records without trouble. Then someone gives it a thousand IDs, and the same code produces a burst of requests, timeouts and retries.
The suspicious line often looks perfectly reasonable:
const records = await Promise.all(ids.map((id) => fetchRecord(id)));
There is no concurrency limit here. map() invokes the callback across the array before Promise.all() waits for the resulting promises. If fetchRecord() starts a request when called, the requests have already been started.
To cap active work, control when you call the function that starts it. This article builds a small pool for a finite array, preserves result order, and makes failures explicit. It is useful for independent I/O tasks such as fetching records or processing files. It does not add CPU parallelism.
The difference between a promise and a job
Consider these two values:
const alreadyStarted = fetchRecord(42);
const startLater = () => fetchRecord(42);
The first is the result of calling the function. The second gives a scheduler the ability to decide when to call it. Putting alreadyStarted into a queue cannot retroactively postpone the request.
This is why wrapping a promise in another function is too late:
// Still starts every request during map():
const promises = ids.map((id) => fetchRecord(id));
const jobs = promises.map((promise) => () => promise);
Keep the input as data instead. Give the pool an ID and a callback; let the pool invoke that callback only when a slot is free. MDN documents Array.prototype.map() as building a new array from callback results. It provides no admission-control mechanism for asynchronous work.
Choose the failure policy before writing the pool
For a batch of independent records, one failed record should not hide the outcomes of the others. The pool below returns one of two result shapes at each input position:
{ status: 'fulfilled', value: record }
{ status: 'rejected', reason: error }
It continues processing after a task fails and resolves after every task settles. That is a deliberate policy, suitable when you want a complete batch report. It would be the wrong default for a workflow where failure makes all remaining work invalid.
Promise.all() rejects when an input promise rejects. That rejection does not cancel the underlying operations. Replacing it with Promise.allSettled() changes how you collect outcomes; it still does not limit when tasks begin.
A small pool with an explicit contract
Save this as pool.mjs. It accepts an array, a positive integer limit, and a callback. The array's values are copied before work starts; objects inside it are still shared references. Empty input returns an empty result array.
The callback must return a value or a promise covering the whole task. If it launches background work and returns early, the pool cannot track that work.
export async function mapSettledLimit(items, limit, task) {
if (!Array.isArray(items)) throw new TypeError('items must be an array');
if (!Number.isSafeInteger(limit) || limit < 1) {
throw new RangeError('limit must be a positive safe integer');
}
if (typeof task !== 'function') throw new TypeError('task must be a function');
const input = Array.from(items);
const results = new Array(input.length);
let next = 0;
async function worker() {
while (next < input.length) {
const index = next++;
try {
const value = await task(input[index], index);
results[index] = { status: 'fulfilled', value };
} catch (reason) {
results[index] = { status: 'rejected', reason };
}
}
}
const count = Math.min(limit, input.length);
await Promise.all(Array.from({ length: count }, () => worker()));
return results;
}
Each worker claims an index, awaits its task, records the outcome, then claims another index. At most limit workers exist. A worker cannot start another callback while it is waiting for its current one.
The index claim contains no await, so workers in this single JavaScript execution context cannot both claim the same index. This is not a locking strategy for shared memory across worker threads.
Results go into results[index], rather than being appended when tasks finish. A slow first task therefore stays first in the returned array. Both synchronous throws and rejected promises become rejected result entries.
Promise.all() still appears in the implementation, but it joins a bounded set of workers. The pool creates promises for the workers, rather than eagerly creating one task promise for every input.
Prove the ceiling without sending real requests
Save this beside it as pool-demo.mjs, then run node pool-demo.mjs with Node.js 22 or newer. The tasks yield to another event-loop turn so that several can remain active together. One task fails deliberately.
import assert from 'node:assert/strict';
import { setImmediate as yieldTurn } from 'node:timers/promises';
import { mapSettledLimit } from './pool.mjs';
let active = 0;
let peak = 0;
const results = await mapSettledLimit([0, 1, 2, 3, 4, 5], 3, async (id) => {
active++;
peak = Math.max(peak, active);
try {
await yieldTurn();
if (id === 2) throw new Error('deliberate failure');
return id * 10;
} finally {
active--;
}
});
assert.equal(peak, 3);
assert.equal(active, 0);
assert.equal(results[2].status, 'rejected');
assert.deepEqual(results.filter((r) => r.status === 'fulfilled').map((r) => r.value),
[0, 10, 30, 40, 50]);
console.log('Peak active tasks:', peak);
console.log('Statuses:', results.map((r) => r.status).join(', '));
The output is:
Peak active tasks: 3
Statuses: fulfilled, fulfilled, rejected, fulfilled, fulfilled, fulfilled
The important assertions are the ceiling, the return to zero active tasks, the recorded failure, and the successful results that follow it. Elapsed time alone would be a weak test: a fast script can still start too much work at once.
For this article, the published example ran on Linux with Node.js 22.22.1. Eight additional local checks passed, covering eager unbounded starts, blocked admission, reverse completion order, synchronous and asynchronous failures, empty input, invalid arguments, and one-worker and oversized-limit cases. These establish the pool's local behaviour, not an API provider's capacity or a production throughput claim.
Put the limit around the resource you need to protect
A callback like this keeps its slot until the response body has been parsed:
const results = await mapSettledLimit(ids, 3, async (id) => {
const response = await fetch(`/api/records/${encodeURIComponent(id)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
});
That URL is an illustrative route in a browser application; replace it with your own endpoint. The explicit await makes the lifetime visible: the task includes reading the JSON, not merely receiving response headers.
If one callback launches ten requests internally, a three-worker pool can still produce thirty active requests. Put admission control at the boundary where the constrained operation actually starts, or make the callback perform one such operation at a time.
Likewise, separate calls to this helper have separate limits. Five batches running together do not share a three-request ceiling. A service-wide limit needs a shared scheduler at the appropriate process or infrastructure boundary.
Concurrency, rate and memory are separate budgets
Three active requests can complete very quickly and be replaced immediately. A concurrency ceiling therefore does not mean three requests per second. A provider's quota may require a rate limiter, bounded retries, or both. When a response is 429, follow the endpoint's retry contract; Jitsumi's guide to Retry-After, backoff and jitter explains that separate decision.
The helper also retains the input snapshot and every result. Its memory usage grows with the batch size. For an endless feed or very large dataset, use a streaming design that bounds buffered input and lets a consumer process results incrementally.
Finally, a hung task holds its slot indefinitely. Timeouts and cancellation belong to the operation's own contract. A timeout that only stops waiting can free a slot while the underlying request continues, defeating the resource limit you thought you had. Do not add automatic retries for writes until you know how the receiving API prevents duplicate effects.
The example is intentionally small: no retry policy, global queue, priority system or cancellation API. Once those requirements matter, evaluate a maintained scheduler against them rather than silently turning this helper into infrastructure.
Before choosing a larger concurrency value, instrument your real workload: active tasks, queue wait, task duration and error rate. Then change one limit and compare. The number is a capacity decision; the essential mechanism is keeping work unstarted until there is room for it.
References
Documentation checked on 5 November 2026.

Top comments (0)