46Implement mapLimit() / throttlePromises() with maximum N concurrent tasks
Problem
Implement mapLimit(items, limit, mapper). It calls the async mapper(item, index) for every item, but never runs more than limit calls at the same time. It resolves with the results in input order.
// Upload 200 photos, at most 4 at a time.
const urls = await mapLimit(photos, 4, (photo) => upload(photo));Requirements
- At most
limitmapper calls are in flight at any moment, and a new call starts as soon as one finishes. - Results keep the input order.
- Reject as soon as one call fails, and do not start any new calls after that.
- A synchronous throw in
mappercounts as a failure. limitmust be at least 1; otherwise reject with aRangeError. An empty input resolves to[].
Try it yourself
Write your solution, run it, then submit to check it against the tests.
Press Run or ⌘ Enter to execute your code.
Step by step
Build the solution one idea at a time.
- 1
Validate first
Reject with a
RangeErrorwhen!(limit >= 1): that catches0, negatives andNaN, which would otherwise start nothing and hang forever. Resolve[]for an empty input. - 2
Share one cursor
Keep
next, the index of the next item to start. Whoever starts a call takesnext++, so every item is started exactly once. - 3
Start `limit` calls
Call
runNext()Math.min(limit, items.length)times. Each call claims an index and starts the mapper. - 4
When a call finishes, start the next one
On success, store the result at its index, count it, and call
runNext()again. That is what keeps exactlylimitcalls running until the list runs out. - 5
Resolve with a counter
When the number of finished calls equals the number of items, resolve with the results. Do not check
next: items can be started but not finished yet. - 6
Stop on failure
On the first error, set
failed = trueand reject.runNextreturns early whenfailedis set, so no new calls start. Calls already running finish, but their results are ignored.
Approach
A fixed number of "slots": each slot runs one call at a time and, when it finishes, claims the next index from a shared cursor. A completion counter decides when to resolve, and a failure flag stops new work.
Solution
function mapLimit(items, limit, mapper) {
return new Promise((resolve, reject) => {
const list = Array.from(items);
if (!(limit >= 1)) {
reject(new RangeError("limit must be at least 1"));
return;
}
const results = new Array(list.length);
if (list.length === 0) {
resolve(results);
return;
}
let next = 0;
let finished = 0;
let failed = false;
function runNext() {
if (failed || next >= list.length) return;
const index = next++;
new Promise((res) => res(mapper(list[index], index))).then(
(value) => {
results[index] = value;
finished++;
if (finished === list.length) resolve(results);
else runNext();
},
(error) => {
failed = true;
reject(error);
},
);
}
for (let i = 0; i < Math.min(limit, list.length); i++) runNext();
});
}Complexity
Time: O(n) bookkeeping; the total wait is roughly the sum of the call times divided by limit. Space: O(n) for the results.
Edge cases
limitbigger than the number of items runs everything at once.limit = 1runs the calls one after another.limit = 0, a negative limit orNaNrejects with aRangeErrorinstead of hanging.- After a failure, calls already running finish but nothing new starts.
- The results keep input order, not completion order.
Follow-up questions
Answer each one out loud first, then reveal the model answer.