mapLimit: Async Map With a Concurrency Cap
Map an array through an async iteratee with at most N in flight. Preserve output order, fail fast, and drain the queue without starving later items.
- javascript
- promises
- async
- concurrency
You need to fetch 200 image URLs. Promise.all on the whole list opens 200 connections. A series loop takes the sum of every RTT. The candidate then writes “batches of 8”: slice, await Promise.all, next slice. One 2s URL in the first batch parks seven finished slots until it lands. That is a chopped-batch illusion, not a concurrency cap.
mapLimit maps each item through an async iteratee with at most N promises in flight. When one settles fulfilled, the next waiting index starts immediately. Results still line up with the input, not with finish order. This is the worker-pool question behind upload queues, crawlers, and polite API clients.
- Cap in-flight work, not batch count. A free slot should pick up the next index the moment it opens.
- Write
results[index] = value. Neverpushon completion.- First iteratee rejection fails the returned promise. Stop launching. In-flight work is not cancelled for free.
- Empty input fulfills with
[].limit < 1is aRangeError.- Slice-then-
allwaits on the slowest of each window and starves later items.
A worker pool, not a chopped-batch illusion
Fix a pool size N. Launch up to N workers. Each worker pulls the next unused index, awaits iteratee(item, index), stores the value at that index, then pulls again until the list is exhausted. The outer promise fulfills when every index has a value (or rejects on the first iteratee failure).
That is series when N === 1 and unbounded parallel when N >= items.length. The interesting case is the middle.
Why batches waste the cap
// Looks limited. Under-uses N whenever a window has a straggler.
async function mapLimitBatched(items, limit, iteratee) {
const results = [];
for (let i = 0; i < items.length; i += limit) {
const slice = items.slice(i, i + limit);
const part = await Promise.all(
slice.map((item, j) => iteratee(item, i + j)),
);
results.push(...part);
}
return results;
}
Take durations [100, 20, 100] and limit = 2.
| Approach | Timeline | Total |
|---|---|---|
| Batches | start 100 and 20; wait 100; then start the last 100 | ~200 |
| Pool | start 100 and 20; at t=20 start the last 100; first 100 overlaps it | ~120 |
Same cap, different utilization. Interviewers who have run this in production will ask you to name that gap. Draw the timeline. Then write the pool.
Procedure
- Allocate
results = new Array(items.length)so slots exist before anyone finishes. - Reject immediately if
limitis not an integer>= 1. - Keep
nextIndexandactivecounts.rejectedlatches the first error. launch: whileactive < limitandnextIndex < length, takeindex = nextIndex++, incrementactive, run the iteratee.- On fulfill:
results[index] = value, decrementactive,launchagain. - On reject: set the latch, reject the outer promise, do not launch more.
- When
nextIndex === lengthandactive === 0, fulfill withresults.
function mapLimit(items, limit, iteratee) {
const cap = Number(limit);
if (!Number.isInteger(cap) || cap < 1) {
return Promise.reject(new RangeError('limit must be an integer >= 1'));
}
const list = items;
const len = list.length;
if (len === 0) return Promise.resolve([]);
const results = new Array(len);
let nextIndex = 0;
let active = 0;
let settled = false;
return new Promise((resolve, reject) => {
const fail = (reason) => {
if (settled) return;
settled = true;
reject(reason);
};
const launch = () => {
if (settled) return;
while (active < cap && nextIndex < len) {
const index = nextIndex++;
active += 1;
Promise.resolve()
.then(() => iteratee(list[index], index))
.then(
(value) => {
if (settled) return;
results[index] = value;
active -= 1;
if (nextIndex >= len && active === 0) {
settled = true;
resolve(results);
return;
}
launch();
},
fail,
);
}
};
launch();
});
}
const urls = ['/a', '/b', '/c', '/d'];
const pages = await mapLimit(urls, 2, (url) =>
fetch(url).then((res) => {
if (!res.ok) throw new Error(String(res.status));
return res.text();
}),
);
// pages[i] belongs to urls[i], even if /c finished first
Promise.resolve().then(() => iteratee(...)) wraps thenables and converts a sync throw into a rejection, matching how Promise.all treats mapper functions.
Workers share nextIndex. That increment is synchronous, on the same turn, so two workers cannot claim the same slot. You do not need a mutex in single-threaded JavaScript for this cursor.
Fail-fast, drain, starve
Fail-fast: the first rejection rejects the outer promise. The settled latch stops further launch calls. Tasks already in flight still run to completion. If you need them to abort, pass an AbortSignal into the iteratee and abort on fail. Join is not cancel. Same rule as AbortController with fetch.
Drain: after a fulfill, the worker immediately tries to pull the next index. Later items are not stuck behind a batch boundary. That is the “do not starve” requirement.
Order: new Array(len) plus index assignment. Completion order is irrelevant. A test that asserts results equals the mapped input in the original sequence will catch the push bug.
Empty lists and a cap larger than n
items.length === 0 resolves [] without launching workers. If limit > length, active never exceeds length; you just run fully parallel. Don’t spawn dummy workers that spin on an empty queue.
Sparse inputs: this implementation walks 0 .. length - 1, so holes call iteratee(undefined, index) and still occupy a slot. Array.prototype.map would skip holes. Say which contract you picked.
mapLimit footguns
awaitinside the launch loop. If youawait iterateein thewhile, you only have one in flight. Launch must be fire-and-forget, with the continuation inthen.- Closing over
indexwrong. Captureconst index = nextIndex++per launch. Avar ishared across thewhilewill race all workers onto the last index. - Resolving twice. After a reject, a late fulfill must not
resolve. Thesettledflag is the guard. - Non-integer
limit.2.7plusactive < limitis a messy cap. Reject, orMath.floorand document it. - Mutating
itemswhile running. The cursor is an index, not a queue copy. Don’tshiftthe source array from the iteratee.
Interview out loud
“I want a pool of at most N workers pulling from a shared index, not slices of N with
Promise.allbetween windows, because a slow item in a batch idles every free slot. Each worker writesresults[index]so output order matches the input. The first rejection fails the map and stops new launches; in-flight work continues unless I pass an AbortSignal. Empty input fulfills with an empty array. limit less than 1 rejects. This sits between series (N = 1) and unbounded parallel (N at least length).”
Further reading
Related on this site
- Run async tasks in series
- Run async tasks in parallel
- Promise.all polyfill
- Promise combinators practice
- The event loop
- AbortController
- JavaScript coding interview patterns
Related guides
- Run Async Tasks in ParallelStart every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- Run Async Tasks in SeriesExecute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.
- Custom Promise From ScratchBuild a tiny Promise: executor, then chaining, microtask reactions, resolve-thenable unwrapping, and the states you are not allowed to leave.
- Promise.all PolyfillImplement Promise.all the way interviews score it: order-preserving results, fail-fast reject, and thenable wrapping with Promise.resolve.
- Promise.allSettled PolyfillWrite Promise.allSettled as a status bag, not a silent all. Fulfilled and rejected entries, order, and why it never rejects for input failures.