Implement mapAsyncLimit(items, limit, asyncMapper) — the bounded-concurrency cousin of mapAsync. The plain parallel version fires every mapper at once; with 1000 items that's 1000 concurrent requests, which exhausts sockets, trips API rate limits, and saturates the browser's connections-per-host cap. mapAsyncLimit adds a knob: process the array with at most limit mappers in flight at any moment, but still resolve with the results in input order — and still fast-fail on the first rejection.
// Returns a Promise that fulfils with results[] in input order.
// At most `limit` mappers run concurrently. Rejects on first failure.
function mapAsyncLimit<T, U>(
items: Iterable<T>,
limit: number,
asyncMapper: (item: T, index: number) => Promise<U> | U,
): Promise<U[]>;
// Basic bounded fetch. limit=2 means at any moment, at most two
// mappers are in flight. The remaining items wait until a slot opens.
const ids = [1, 2, 3, 4, 5];
const users = await mapAsyncLimit(ids, 2, (id) =>
fetch(`/api/users/${id}`).then((r) => r.json()),
);
// users === [user1, user2, user3, user4, user5]
// Fast-fail. The first rejection wins; later successful mappers
// have their results discarded, and in-flight ones aren't waited on
// for a new claim (though they can't be cancelled).
await mapAsyncLimit([1, 2, 3, 4], 2, async (n) => {
if (n === 2) throw new Error('boom');
return n * 10;
}); // throws Error('boom')
// limit greater than items.length behaves like mapAsync — every
// mapper kicks off in the same tick; wall-clock = max(per-item time).
await mapAsyncLimit([10, 20, 30], 100, async (n) => n * 2);
// → [20, 40, 60]
// limit = 1 forces sequential execution. Wall-clock = sum of times,
// not max. Useful for rate-limited APIs (one in flight at a time).
await mapAsyncLimit([1, 2, 3], 1, async (n) => n);
// → [1, 2, 3]
limit in flight. Not "process in chunks of limit" — that idles workers. A free worker should grab the next item the moment it finishes, even if other workers are still busy.results[i] always holds the value of asyncMapper(items[i], i) — even if mapper i finishes last.(item, index). Same shape as Array.prototype.map. The third argument is not required.Promise.all semantics: the outer promise rejects the moment any mapper rejects. Don't wait for in-flight workers to finish before rejecting; just stop claiming new items.limit <= 0 throws a RangeError. Zero workers means no progress; that's a programmer mistake, not a runtime condition to silently accept.limit = Infinity is allowed and behaves like the unbounded mapAsync — every mapper runs in parallel.Sets. The function snapshots the iterable into an array at the start (generators are single-pass; we need indexable access for results[i]).mapAsync.You'll cap how many async mappers run at once while still resolving in input order and fast-failing on the first rejection — the building block behind p-limit and every "bounded concurrency" helper you've ever pulled from npm.
The unbounded version — mapAsync — fires every mapper in the same synchronous pass. That's fine for a handful of items. With 1000 items, you've just opened 1000 concurrent connections. Three things break in production:
The fix is one knob: at most limit mappers in flight at any moment. Items beyond limit wait for a slot to open. Results still come back in input order. The first rejection still wins. Everything that made mapAsync work stays — we just gate the fan-out.
Picture a small fixed pool of workers sharing a single cursor. The cursor starts at 0; each worker, when free, atomically grabs the cursor's value into a local i, increments the cursor, runs asyncMapper(items[i], i), writes the result into results[i], and loops. When the cursor runs past the end, the worker exits. When every worker has exited, the outer Promise resolves with results.
Two invariants do the heavy lifting. First: the cursor is the only queue. No explicit FIFO, no array shifts — just an index that goes up. Because JavaScript is single-threaded, const i = cursor++; is atomic; no two workers ever read the same i. Second: results are written by index, not appended. results[i] = value always lands in the right slot whether the mapper finished first or last. The whole input-order guarantee rides on that one line.
Two naive shapes show up first. Both are instructive — they motivate every piece of the working solution.
Attempt 1 — slice into chunks of limit, then Promise.all each chunk sequentially.
async function chunked(items, limit, mapper) {
const results = [];
for (let i = 0; i < items.length; i += limit) {
const chunk = items.slice(i, i + limit);
const mapped = await Promise.all(chunk.map((x, j) => mapper(x, i + j)));
results.push(...mapped);
}
return results;
}
It reads cleanly and the result array is correct. The problem is timing. await Promise.all(chunk) waits for every member of that chunk before starting the next one. If the chunk has one slow item and one fast item, the fast worker finishes early and then idles until the slow one is done.
Concrete numbers: input [100ms, 10ms, 100ms, 10ms], limit = 2. Chunked runs [100, 10] together (finishes at 100ms — the slow one), then [100, 10] again (finishes at 200ms). Total: 200ms. The pool version finishes the same work in 110ms — worker B knocks out item 1 at t=10, immediately grabs item 2 at t=10, and item 2 finishes at t=110.
The fix isn't "make the chunks smaller" — that converges to limit = 1 in the limit. The fix is structural: workers should not be tied to chunks at all.
Attempt 2 — recursive next() continuation.
function recursive(items, limit, mapper) {
return new Promise((resolve, reject) => {
const results = new Array(items.length);
let i = 0, completed = 0;
function next() {
if (i >= items.length) return;
const myI = i++;
Promise.resolve(mapper(items[myI], myI)).then(
(v) => {
results[myI] = v;
if (++completed === items.length) resolve(results);
else next();
},
reject,
);
}
for (let k = 0; k < Math.min(limit, items.length); k++) next();
});
}
This works for the happy path. But the control flow is a continuation: each completion calls next() to keep the chain alive, and that's hard to reason about. Worse, error handling is fragile — if mapper throws synchronously, the Promise.resolve() wrap catches it, but the reject callback fires once per failing mapper and the surviving callbacks keep calling next() until the cursor exhausts. There's no await Promise.all shape to anchor the outer settle on, and the "are we done" signal is completed === items.length, which is brittle if a mapper somehow double-resolves (which thenables can do).
The async rewrite below uses a while loop inside each worker and a top-level await Promise.all(workers) for the gather. Both are easier to read and easier to verify line by line.
async function mapAsyncLimit(items, limit, asyncMapper) {
// Snapshot the iterable into an indexable array up front. Generators and
// Sets are single-pass and don't support items[i], but we need both
// indexed read (items[i]) and indexed write (results[i]). One Array.from
// pays for both. Array.isArray short-circuits to avoid copying an array.
const arr = Array.isArray(items) ? items : Array.from(items);
// Zero or negative workers means no progress — that's a programmer
// mistake, not a runtime condition. Reject loudly. Note: limit = Infinity
// is fine; Math.min(Infinity, arr.length) === arr.length below.
if (limit <= 0) throw new RangeError('limit must be > 0');
// Empty input is the trivial case. Returning [] here avoids spawning
// any workers and avoids the Promise.all([]) microtask, which would
// otherwise resolve on the next tick — same observable behaviour but
// one fewer microtask.
if (arr.length === 0) return [];
// Pre-allocate by length so results[i] = value works at any index,
// including indices higher than what's already been written. Using
// [] and assigning results[2] before results[0] and [1] would leave
// sparse holes — assigning into a pre-sized array doesn't.
const results = new Array(arr.length);
// The shared cursor. Every worker reads-and-increments it atomically
// (i = cursor++) when it needs a new item. JS is single-threaded, so
// no two workers can ever observe the same value of `cursor` between
// the read and the increment. In Java or Go you'd need a mutex; here
// the event loop is the mutex.
let cursor = 0;
// Fast-fail latches. As soon as any mapper rejects, every other worker
// sees `rejected === true` on its next loop check and exits without
// claiming another item. We hold the reason on the side so we can
// throw it once Promise.all has gathered every worker.
let rejected = false;
let rejectionReason;
async function worker() {
// Two exit conditions: cursor exhausted (normal path) OR another
// worker has already rejected (fast-fail path). Both checked on
// every iteration, including the first — so a synchronous-throw
// mapper in worker A makes worker B exit before its first claim
// if A's throw lands first.
while (cursor < arr.length && !rejected) {
const i = cursor++; // atomic claim + advance — see comment on `cursor`
try {
// The await here is what bounds concurrency. While this worker
// is suspended on the mapper, other workers continue their own
// loops. At any moment, at most `workerCount` mappers are in
// flight — that's the whole guarantee, falling out of the fact
// that this worker can't claim a new item until this await
// resolves.
results[i] = await asyncMapper(arr[i], i);
} catch (err) {
// Don't re-throw — that would make this worker's promise reject
// and short-circuit Promise.all in a way we can't control (the
// outer would reject before sibling workers' awaits resolved).
// Instead, flip the shared flag, stash the reason, and return.
// The outer Promise.all still waits for the other workers to
// see the flag and exit cleanly; THEN we throw.
rejected = true;
rejectionReason = err;
return;
}
}
}
// Spawn at most arr.length workers. If limit is 100 and we only have
// 5 items, spawning 100 workers just to immediately exit them on the
// first cursor check is wasteful — Math.min trims that.
const workerCount = Math.min(limit, arr.length);
const workers = Array.from({ length: workerCount }, () => worker());
// Promise.all is the gather: it settles when every worker's async
// function has returned. Because no worker re-throws (see above),
// none of these can reject — Promise.all only resolves here.
await Promise.all(workers);
// Now that every worker has exited (either cleanly or via the
// rejected-flag short-circuit), surface the first rejection. Throwing
// here instead of inside a worker means the outer Promise rejects
// with the same `err` that the first failing mapper produced, with no
// race on which sibling settles first.
if (rejected) throw rejectionReason;
return results;
}
module.exports = { mapAsyncLimit };
Three shifts from the chunked version. First, the worker loop pulls the next item the instant it's free — no chunk barrier, no idle gap. Second, results[i] = value is index-keyed, so the output order is the input order regardless of which worker did the work or when it finished. Third, the rejection path is two-phase: a worker flips the flag and returns rather than re-throwing, then the outer code throws after Promise.all has reaped every worker. That avoids the race where the outer rejection settles before sibling workers' in-flight awaits resolve — which would otherwise leave dangling promises and surprise the caller with late unhandled-rejection warnings.
Two traces. The first shows timing; the second shows fast-fail.
mapAsyncLimit([100, 10, 100, 10], 2, sleep) where sleep(ms) waits ms then returns ms.Setup: arr = [100, 10, 100, 10], results = [<empty> × 4], cursor = 0, workerCount = 2.
t = 0. Both workers spawn. Worker A enters its while: cursor = 0, claims i = 0, cursor becomes 1. Calls await asyncMapper(100, 0) — suspends for 100ms. Worker B enters its while: cursor = 1, claims i = 1, cursor becomes 2. Calls await asyncMapper(10, 1) — suspends for 10ms.
t = 10. Worker B's await resolves with 10. results[1] = 10. Loop check: cursor (2) < arr.length (4) && !rejected — yes. Claims i = 2, cursor becomes 3. Calls await asyncMapper(100, 2) — suspends for 100ms.
t = 100. Worker A's await resolves with 100. results[0] = 100. Loop check: cursor (3) < 4 && !rejected — yes. Claims i = 3, cursor becomes 4. Calls await asyncMapper(10, 3) — suspends for 10ms.
t = 110. Worker A's await resolves with 10. results[3] = 10. Loop check: cursor (4) < 4 — false. Worker A's while exits; the async function returns. Also at t = 110: worker B's await on asyncMapper(100, 2) (started at t = 10) resolves with 100. results[2] = 100. Loop check: cursor (4) < 4 — false. Worker B exits.
Gather. Promise.all([workerA, workerB]) resolves. rejected is false, so we return results = [100, 10, 100, 10]. Outer Promise fulfils. Wall-clock: 110ms.
The chunked version on the same input takes 200ms because chunk 1 ([100, 10]) doesn't release until the 100ms task lands at t=100, and only then can chunk 2 start. The pool's 90ms saving is real wall-clock — and that gap grows linearly with input size on bursty workloads.
mapAsyncLimit([sleep80, reject20, sleep20, sleep20, sleep20], 2, run) where sleep80 resolves at 80ms, reject20 rejects at 20ms, and sleep20s each take 20ms.
t = 0. Worker A claims i = 0 (sleep80, 80ms). Worker B claims i = 1 (reject20, 20ms). cursor = 2.
t = 20. Worker B's await rejects. The catch runs: rejected = true, rejectionReason = <error>, worker B returns. Worker A is still awaiting on its 80ms task — the event loop has no way to cancel it.
t = 20 onward. Items 2, 3, 4 sit at indices 2, 3, 4 — never claimed. The cursor is at 2, but the only worker still alive (A) is inside its await, not back in its loop.
t = 80. Worker A's await resolves. results[0] = 80. Loop check: cursor (2) < 5 && !rejected — rejected is true. Loop exits. Worker A returns.
Gather. Promise.all([A, B]) settles. rejected is true, so we throw rejectionReason. The outer Promise rejects with the same error worker B's mapper threw — 60ms after the rejection actually happened, but with no dangling in-flight work.
The trade-off here is real and intentional. If you wanted the outer Promise to reject at exactly t=20ms, you'd have to walk away from Promise.all on the workers and use a Promise.race between workerCount worker promises and a "rejection sentinel" promise. That's possible but it leaves the in-flight workers as orphans — they keep running, write into results after the outer has already rejected, and produce ghostly side effects. The two-phase shape we chose keeps everything clean at the cost of one settled-but-stuck await per worker.
[100, 10, 100, 10] with limit=2: pool finishes at 110ms, chunked at 200ms. The gap grows the more your per-item times vary. The fix is structural — workers shouldn't be tied to chunks.results.push(value) produces the wrong output. Promise.all would preserve order for you; we don't have that here. If a slow item 0 is still running while fast items 1 and 2 finish, push gives you [v1, v2, v0]. Always write results[i] = value instead.limit = 0 deadlocks if you don't handle it. Math.min(0, arr.length) is 0, so we spawn zero workers, Promise.all([]) resolves to [] immediately, and the function returns a results array full of undefined — silently wrong. Throw RangeError up front; it's a caller bug.cursor++ is atomic in JavaScript, but only in JavaScript. Two workers can never race on the increment because the event loop only runs one synchronous slice at a time. If you port this code to a multi-threaded runtime (Web Workers with shared memory, Rust, Go), you need an atomic counter or a mutex — the implicit guarantee disappears.Array.from up front. for...of over a generator works, but results[i] after the loop is meaningless without indexed access. Array.from(items) walks the generator once, materialises the array, and lets every subsequent operation be O(1) random access. Don't try to be clever with iterator.next() calls split across workers — interleaved .next() calls on the same iterator races in nasty ways.AbortSignal through to the mapper and have it bail) and lives in the next section.allSettled: pick the right default. Promise.all semantics is what most callers expect, so we ship that. But if you're processing 500 dashboard widgets and one failure shouldn't blank the page, you want allSettled-style accumulation — see Going further.allSettled variant. Instead of fast-failing on the first rejection, collect every outcome as { status: 'fulfilled', value } or { status: 'rejected', reason }. The structural change is small — the worker's catch writes a settled-shape object into results[i] instead of flipping the rejection flag, and you drop the if (rejected) throw at the end. Useful for "best-effort" batches: render the 47 widgets that loaded, mark the 3 that errored.AbortSignal support. Take a { signal } option, attach a listener that flips rejected = true and rejects with signal.reason, and forward signal to the mapper so individual mappers can bail themselves. This doesn't truly cancel in-flight work — promises aren't cancellable — but it stops new claims immediately and lets cooperative mappers (e.g. fetch(url, { signal })) abort their own underlying I/O.setLimit(n) callback or a reactive limit source; when n shrinks below the current worker count, excess workers exit on their next loop check (add workerCount > currentLimit to the exit condition); when it grows, spawn new workers and push them into the workers array. Useful when you're auto-tuning to API rate-limit responses or backpressure signals from a downstream queue.results[i] for the original index i); only the claim order changes. This is the building block behind libraries like bottleneck that ship "reservoir" semantics on top of p-limit.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement mapAsyncLimit(items, limit, asyncMapper) — the bounded-concurrency cousin of mapAsync. The plain parallel version fires every mapper at once; with 1000 items that's 1000 concurrent requests, which exhausts sockets, trips API rate limits, and saturates the browser's connections-per-host cap. mapAsyncLimit adds a knob: process the array with at most limit mappers in flight at any moment, but still resolve with the results in input order — and still fast-fail on the first rejection.
// Returns a Promise that fulfils with results[] in input order.
// At most `limit` mappers run concurrently. Rejects on first failure.
function mapAsyncLimit<T, U>(
items: Iterable<T>,
limit: number,
asyncMapper: (item: T, index: number) => Promise<U> | U,
): Promise<U[]>;
// Basic bounded fetch. limit=2 means at any moment, at most two
// mappers are in flight. The remaining items wait until a slot opens.
const ids = [1, 2, 3, 4, 5];
const users = await mapAsyncLimit(ids, 2, (id) =>
fetch(`/api/users/${id}`).then((r) => r.json()),
);
// users === [user1, user2, user3, user4, user5]
// Fast-fail. The first rejection wins; later successful mappers
// have their results discarded, and in-flight ones aren't waited on
// for a new claim (though they can't be cancelled).
await mapAsyncLimit([1, 2, 3, 4], 2, async (n) => {
if (n === 2) throw new Error('boom');
return n * 10;
}); // throws Error('boom')
// limit greater than items.length behaves like mapAsync — every
// mapper kicks off in the same tick; wall-clock = max(per-item time).
await mapAsyncLimit([10, 20, 30], 100, async (n) => n * 2);
// → [20, 40, 60]
// limit = 1 forces sequential execution. Wall-clock = sum of times,
// not max. Useful for rate-limited APIs (one in flight at a time).
await mapAsyncLimit([1, 2, 3], 1, async (n) => n);
// → [1, 2, 3]
limit in flight. Not "process in chunks of limit" — that idles workers. A free worker should grab the next item the moment it finishes, even if other workers are still busy.results[i] always holds the value of asyncMapper(items[i], i) — even if mapper i finishes last.(item, index). Same shape as Array.prototype.map. The third argument is not required.Promise.all semantics: the outer promise rejects the moment any mapper rejects. Don't wait for in-flight workers to finish before rejecting; just stop claiming new items.limit <= 0 throws a RangeError. Zero workers means no progress; that's a programmer mistake, not a runtime condition to silently accept.limit = Infinity is allowed and behaves like the unbounded mapAsync — every mapper runs in parallel.Sets. The function snapshots the iterable into an array at the start (generators are single-pass; we need indexable access for results[i]).mapAsync.You'll cap how many async mappers run at once while still resolving in input order and fast-failing on the first rejection — the building block behind p-limit and every "bounded concurrency" helper you've ever pulled from npm.
The unbounded version — mapAsync — fires every mapper in the same synchronous pass. That's fine for a handful of items. With 1000 items, you've just opened 1000 concurrent connections. Three things break in production:
The fix is one knob: at most limit mappers in flight at any moment. Items beyond limit wait for a slot to open. Results still come back in input order. The first rejection still wins. Everything that made mapAsync work stays — we just gate the fan-out.
Picture a small fixed pool of workers sharing a single cursor. The cursor starts at 0; each worker, when free, atomically grabs the cursor's value into a local i, increments the cursor, runs asyncMapper(items[i], i), writes the result into results[i], and loops. When the cursor runs past the end, the worker exits. When every worker has exited, the outer Promise resolves with results.
Two invariants do the heavy lifting. First: the cursor is the only queue. No explicit FIFO, no array shifts — just an index that goes up. Because JavaScript is single-threaded, const i = cursor++; is atomic; no two workers ever read the same i. Second: results are written by index, not appended. results[i] = value always lands in the right slot whether the mapper finished first or last. The whole input-order guarantee rides on that one line.
Two naive shapes show up first. Both are instructive — they motivate every piece of the working solution.
Attempt 1 — slice into chunks of limit, then Promise.all each chunk sequentially.
async function chunked(items, limit, mapper) {
const results = [];
for (let i = 0; i < items.length; i += limit) {
const chunk = items.slice(i, i + limit);
const mapped = await Promise.all(chunk.map((x, j) => mapper(x, i + j)));
results.push(...mapped);
}
return results;
}
It reads cleanly and the result array is correct. The problem is timing. await Promise.all(chunk) waits for every member of that chunk before starting the next one. If the chunk has one slow item and one fast item, the fast worker finishes early and then idles until the slow one is done.
Concrete numbers: input [100ms, 10ms, 100ms, 10ms], limit = 2. Chunked runs [100, 10] together (finishes at 100ms — the slow one), then [100, 10] again (finishes at 200ms). Total: 200ms. The pool version finishes the same work in 110ms — worker B knocks out item 1 at t=10, immediately grabs item 2 at t=10, and item 2 finishes at t=110.
The fix isn't "make the chunks smaller" — that converges to limit = 1 in the limit. The fix is structural: workers should not be tied to chunks at all.
Attempt 2 — recursive next() continuation.
function recursive(items, limit, mapper) {
return new Promise((resolve, reject) => {
const results = new Array(items.length);
let i = 0, completed = 0;
function next() {
if (i >= items.length) return;
const myI = i++;
Promise.resolve(mapper(items[myI], myI)).then(
(v) => {
results[myI] = v;
if (++completed === items.length) resolve(results);
else next();
},
reject,
);
}
for (let k = 0; k < Math.min(limit, items.length); k++) next();
});
}
This works for the happy path. But the control flow is a continuation: each completion calls next() to keep the chain alive, and that's hard to reason about. Worse, error handling is fragile — if mapper throws synchronously, the Promise.resolve() wrap catches it, but the reject callback fires once per failing mapper and the surviving callbacks keep calling next() until the cursor exhausts. There's no await Promise.all shape to anchor the outer settle on, and the "are we done" signal is completed === items.length, which is brittle if a mapper somehow double-resolves (which thenables can do).
The async rewrite below uses a while loop inside each worker and a top-level await Promise.all(workers) for the gather. Both are easier to read and easier to verify line by line.
async function mapAsyncLimit(items, limit, asyncMapper) {
// Snapshot the iterable into an indexable array up front. Generators and
// Sets are single-pass and don't support items[i], but we need both
// indexed read (items[i]) and indexed write (results[i]). One Array.from
// pays for both. Array.isArray short-circuits to avoid copying an array.
const arr = Array.isArray(items) ? items : Array.from(items);
// Zero or negative workers means no progress — that's a programmer
// mistake, not a runtime condition. Reject loudly. Note: limit = Infinity
// is fine; Math.min(Infinity, arr.length) === arr.length below.
if (limit <= 0) throw new RangeError('limit must be > 0');
// Empty input is the trivial case. Returning [] here avoids spawning
// any workers and avoids the Promise.all([]) microtask, which would
// otherwise resolve on the next tick — same observable behaviour but
// one fewer microtask.
if (arr.length === 0) return [];
// Pre-allocate by length so results[i] = value works at any index,
// including indices higher than what's already been written. Using
// [] and assigning results[2] before results[0] and [1] would leave
// sparse holes — assigning into a pre-sized array doesn't.
const results = new Array(arr.length);
// The shared cursor. Every worker reads-and-increments it atomically
// (i = cursor++) when it needs a new item. JS is single-threaded, so
// no two workers can ever observe the same value of `cursor` between
// the read and the increment. In Java or Go you'd need a mutex; here
// the event loop is the mutex.
let cursor = 0;
// Fast-fail latches. As soon as any mapper rejects, every other worker
// sees `rejected === true` on its next loop check and exits without
// claiming another item. We hold the reason on the side so we can
// throw it once Promise.all has gathered every worker.
let rejected = false;
let rejectionReason;
async function worker() {
// Two exit conditions: cursor exhausted (normal path) OR another
// worker has already rejected (fast-fail path). Both checked on
// every iteration, including the first — so a synchronous-throw
// mapper in worker A makes worker B exit before its first claim
// if A's throw lands first.
while (cursor < arr.length && !rejected) {
const i = cursor++; // atomic claim + advance — see comment on `cursor`
try {
// The await here is what bounds concurrency. While this worker
// is suspended on the mapper, other workers continue their own
// loops. At any moment, at most `workerCount` mappers are in
// flight — that's the whole guarantee, falling out of the fact
// that this worker can't claim a new item until this await
// resolves.
results[i] = await asyncMapper(arr[i], i);
} catch (err) {
// Don't re-throw — that would make this worker's promise reject
// and short-circuit Promise.all in a way we can't control (the
// outer would reject before sibling workers' awaits resolved).
// Instead, flip the shared flag, stash the reason, and return.
// The outer Promise.all still waits for the other workers to
// see the flag and exit cleanly; THEN we throw.
rejected = true;
rejectionReason = err;
return;
}
}
}
// Spawn at most arr.length workers. If limit is 100 and we only have
// 5 items, spawning 100 workers just to immediately exit them on the
// first cursor check is wasteful — Math.min trims that.
const workerCount = Math.min(limit, arr.length);
const workers = Array.from({ length: workerCount }, () => worker());
// Promise.all is the gather: it settles when every worker's async
// function has returned. Because no worker re-throws (see above),
// none of these can reject — Promise.all only resolves here.
await Promise.all(workers);
// Now that every worker has exited (either cleanly or via the
// rejected-flag short-circuit), surface the first rejection. Throwing
// here instead of inside a worker means the outer Promise rejects
// with the same `err` that the first failing mapper produced, with no
// race on which sibling settles first.
if (rejected) throw rejectionReason;
return results;
}
module.exports = { mapAsyncLimit };
Three shifts from the chunked version. First, the worker loop pulls the next item the instant it's free — no chunk barrier, no idle gap. Second, results[i] = value is index-keyed, so the output order is the input order regardless of which worker did the work or when it finished. Third, the rejection path is two-phase: a worker flips the flag and returns rather than re-throwing, then the outer code throws after Promise.all has reaped every worker. That avoids the race where the outer rejection settles before sibling workers' in-flight awaits resolve — which would otherwise leave dangling promises and surprise the caller with late unhandled-rejection warnings.
Two traces. The first shows timing; the second shows fast-fail.
mapAsyncLimit([100, 10, 100, 10], 2, sleep) where sleep(ms) waits ms then returns ms.Setup: arr = [100, 10, 100, 10], results = [<empty> × 4], cursor = 0, workerCount = 2.
t = 0. Both workers spawn. Worker A enters its while: cursor = 0, claims i = 0, cursor becomes 1. Calls await asyncMapper(100, 0) — suspends for 100ms. Worker B enters its while: cursor = 1, claims i = 1, cursor becomes 2. Calls await asyncMapper(10, 1) — suspends for 10ms.
t = 10. Worker B's await resolves with 10. results[1] = 10. Loop check: cursor (2) < arr.length (4) && !rejected — yes. Claims i = 2, cursor becomes 3. Calls await asyncMapper(100, 2) — suspends for 100ms.
t = 100. Worker A's await resolves with 100. results[0] = 100. Loop check: cursor (3) < 4 && !rejected — yes. Claims i = 3, cursor becomes 4. Calls await asyncMapper(10, 3) — suspends for 10ms.
t = 110. Worker A's await resolves with 10. results[3] = 10. Loop check: cursor (4) < 4 — false. Worker A's while exits; the async function returns. Also at t = 110: worker B's await on asyncMapper(100, 2) (started at t = 10) resolves with 100. results[2] = 100. Loop check: cursor (4) < 4 — false. Worker B exits.
Gather. Promise.all([workerA, workerB]) resolves. rejected is false, so we return results = [100, 10, 100, 10]. Outer Promise fulfils. Wall-clock: 110ms.
The chunked version on the same input takes 200ms because chunk 1 ([100, 10]) doesn't release until the 100ms task lands at t=100, and only then can chunk 2 start. The pool's 90ms saving is real wall-clock — and that gap grows linearly with input size on bursty workloads.
mapAsyncLimit([sleep80, reject20, sleep20, sleep20, sleep20], 2, run) where sleep80 resolves at 80ms, reject20 rejects at 20ms, and sleep20s each take 20ms.
t = 0. Worker A claims i = 0 (sleep80, 80ms). Worker B claims i = 1 (reject20, 20ms). cursor = 2.
t = 20. Worker B's await rejects. The catch runs: rejected = true, rejectionReason = <error>, worker B returns. Worker A is still awaiting on its 80ms task — the event loop has no way to cancel it.
t = 20 onward. Items 2, 3, 4 sit at indices 2, 3, 4 — never claimed. The cursor is at 2, but the only worker still alive (A) is inside its await, not back in its loop.
t = 80. Worker A's await resolves. results[0] = 80. Loop check: cursor (2) < 5 && !rejected — rejected is true. Loop exits. Worker A returns.
Gather. Promise.all([A, B]) settles. rejected is true, so we throw rejectionReason. The outer Promise rejects with the same error worker B's mapper threw — 60ms after the rejection actually happened, but with no dangling in-flight work.
The trade-off here is real and intentional. If you wanted the outer Promise to reject at exactly t=20ms, you'd have to walk away from Promise.all on the workers and use a Promise.race between workerCount worker promises and a "rejection sentinel" promise. That's possible but it leaves the in-flight workers as orphans — they keep running, write into results after the outer has already rejected, and produce ghostly side effects. The two-phase shape we chose keeps everything clean at the cost of one settled-but-stuck await per worker.
[100, 10, 100, 10] with limit=2: pool finishes at 110ms, chunked at 200ms. The gap grows the more your per-item times vary. The fix is structural — workers shouldn't be tied to chunks.results.push(value) produces the wrong output. Promise.all would preserve order for you; we don't have that here. If a slow item 0 is still running while fast items 1 and 2 finish, push gives you [v1, v2, v0]. Always write results[i] = value instead.limit = 0 deadlocks if you don't handle it. Math.min(0, arr.length) is 0, so we spawn zero workers, Promise.all([]) resolves to [] immediately, and the function returns a results array full of undefined — silently wrong. Throw RangeError up front; it's a caller bug.cursor++ is atomic in JavaScript, but only in JavaScript. Two workers can never race on the increment because the event loop only runs one synchronous slice at a time. If you port this code to a multi-threaded runtime (Web Workers with shared memory, Rust, Go), you need an atomic counter or a mutex — the implicit guarantee disappears.Array.from up front. for...of over a generator works, but results[i] after the loop is meaningless without indexed access. Array.from(items) walks the generator once, materialises the array, and lets every subsequent operation be O(1) random access. Don't try to be clever with iterator.next() calls split across workers — interleaved .next() calls on the same iterator races in nasty ways.AbortSignal through to the mapper and have it bail) and lives in the next section.allSettled: pick the right default. Promise.all semantics is what most callers expect, so we ship that. But if you're processing 500 dashboard widgets and one failure shouldn't blank the page, you want allSettled-style accumulation — see Going further.allSettled variant. Instead of fast-failing on the first rejection, collect every outcome as { status: 'fulfilled', value } or { status: 'rejected', reason }. The structural change is small — the worker's catch writes a settled-shape object into results[i] instead of flipping the rejection flag, and you drop the if (rejected) throw at the end. Useful for "best-effort" batches: render the 47 widgets that loaded, mark the 3 that errored.AbortSignal support. Take a { signal } option, attach a listener that flips rejected = true and rejects with signal.reason, and forward signal to the mapper so individual mappers can bail themselves. This doesn't truly cancel in-flight work — promises aren't cancellable — but it stops new claims immediately and lets cooperative mappers (e.g. fetch(url, { signal })) abort their own underlying I/O.setLimit(n) callback or a reactive limit source; when n shrinks below the current worker count, excess workers exit on their next loop check (add workerCount > currentLimit to the exit condition); when it grows, spawn new workers and push them into the workers array. Useful when you're auto-tuning to API rate-limit responses or backpressure signals from a downstream queue.results[i] for the original index i); only the claim order changes. This is the building block behind libraries like bottleneck that ship "reservoir" semantics on top of p-limit.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.