A batch of asynchronous jobs can overwhelm a dependency if every operation starts at once. Sometimes the desired rule is simple: run at most two jobs together, attempt every item, and return each success or failure in the original input order.
This JavaScript example implements that rule with a small worker pool. It uses simulated asynchronous work so you can run it without an API key or network service. The complete project ZIP contains both source files below, thirteen tests, a README and exact expected output.
Prerequisites and the contract
Use Node.js 22 or later. The checks ran on Node.js 24.16.0 on Windows; other runtimes and browsers were not separately tested. That version records the available test environment, not the latest recommended patch. There is no npm install step or third-party dependency.
node --version
Extract the ZIP and open a terminal inside javascript-async-limit. The helper accepts an array, an integer limit from 1 through 100, and a mapper function. The mapper receives a value and its original index. A successful item gets a fulfilled result with its value; a failed item gets a rejected result with the original rejection reason.
Save map-limit.mjs
// mapper must create/start the work when called, not receive pre-started promises.
export async function mapLimitSettled(items, limit, mapper) {
if (!Array.isArray(items)) throw new TypeError('items must be an array');
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
throw new RangeError('limit must be an integer from 1 to 100');
}
if (typeof mapper !== 'function') throw new TypeError('mapper must be a function');
const input = Array.from(items); // Shallow snapshot; holes become undefined.
const results = new Array(input.length);
let next = 0;
async function worker() {
while (next < input.length) {
// Claim an index synchronously, before yielding at await.
const index = next++;
try {
const value = await mapper(input[index], index);
results[index] = { status: 'fulfilled', value };
} catch (reason) {
results[index] = { status: 'rejected', reason };
}
}
}
const workers = Array.from(
{ length: Math.min(limit, input.length) },
() => worker(),
);
await Promise.all(workers);
return results;
}
Each worker claims one index before awaiting the mapper. Other workers claim their own indexes while that job waits. When a job settles, that worker takes another item. Writing results at the claimed index preserves input order even when later jobs finish first.
The helper takes a shallow snapshot of the input array. Later edits to the array's length or slots do not add jobs to this batch, but objects inside it are still shared references. Sparse slots are processed as undefined. It does not deep-clone application data.
Save demo.mjs and run it
import { setImmediate } from 'node:timers/promises';
import { mapLimitSettled } from './map-limit.mjs';
const jobs = ['alpha', 'beta', 'gamma', 'delta'];
let active = 0;
let peak = 0;
const results = await mapLimitSettled(jobs, 2, async (name) => {
active++;
peak = Math.max(peak, active);
try {
await setImmediate(); // Simulated asynchronous work; no network request.
if (name === 'beta') throw new Error('demo failure');
return name.toUpperCase();
} finally {
active--;
}
});
console.log(`Peak active jobs: ${peak}`);
results.forEach((result, index) => {
const detail = result.status === 'fulfilled'
? result.value
: result.reason.message;
console.log(`${jobs[index]}: ${result.status} - ${detail}`);
});
node demo.mjs
The expected standard output is:
Peak active jobs: 2
alpha: fulfilled - ALPHA
beta: rejected - demo failure
gamma: fulfilled - GAMMA
delta: fulfilled - DELTA
The demo yields through Node's setImmediate promise rather than contacting a server. Beta deliberately rejects, but gamma and delta still produce results. The printed peak is an observation of this fixture's active jobs, not a performance measurement. The ordered report is printed only after all four jobs settle.
Why a result collector alone does not limit starts
The ECMAScript Promise.allSettled specification describes collecting fulfilled and rejected outcomes for an iterable. It does not provide a worker-count setting. If an array map callback starts each operation before passing its promises to a collector, those operations have already started.
The boundary in this example is the mapper invocation: create the work when that function is called. Passing a list of already-running promises to a mapper cannot retroactively limit the underlying work. Likewise, a mapper that launches detached operations and immediately returns has ended its observable lifetime too early for this pool to control those operations.
Distinguish item failures from invalid calls
A synchronous mapper throw and an asynchronous rejection are caught per item. The worker records the reason and continues. A rejection reason can be any JavaScript value; the demo reads message only because its own fixture throws Error objects. A real result renderer should handle the failure values its mapper can produce.
Invalid input instead rejects the helper's returned promise before jobs start. Examples include a zero, fractional or excessive limit, a non-array input, or a nonfunction mapper. Callers must await and handle that validation rejection. The limit range of 1-100 is this example's explicit API policy, not a JavaScript language limit.
Run the thirteen tests
node --test map-limit.test.mjs
All thirteen cases passed. They check the in-flight cap and unique item claims, reversed completion order, asynchronous rejection, synchronous throws, synchronous mapper values and indexes, empty input, a limit above item count, serial scheduling, invalid limits, non-array inputs, invalid mapper, later array edits and sparse slots. The central scheduling cases use manually released promises instead of assuming that a short timer proves concurrency behavior.
For example, while two jobs are held open, the test confirms that the third has not started. Releasing one gate must start exactly the next job while keeping two active. This checks the actual scheduling boundary that the helper promises to enforce.
The Node.js test-runner documentation explains running tests with node --test, and the assert reference documents the strict comparisons and rejection checks used here. All three JavaScript files passed syntax checks. The ZIP was extracted into a separate temporary directory, then its tests and exact demo output passed again.
Know what the limit does not promise
This pool controls mapper calls awaiting settlement. It does not create CPU threads, set requests per second, enforce a shared limit across processes, or supply retries, persistence, cancellation or timeouts. A mapper that never settles keeps one worker occupied indefinitely and prevents the final batch result from completing.
Use the helper when each job's complete lifetime can be represented by the returned value or promise. If the application needs cancellation, define how cancellation reaches the underlying operation and what happens to queued items before extending this example. Keep that behavior explicit and add cases for it; simply rejecting a wrapper promise does not prove the underlying operation stopped.
Comments (0)
Join the discussion