Implement mapAsync(items, asyncMapper) — the async cousin of Array.prototype.map. You take an iterable of items and a mapper that returns a promise. You apply the mapper to every item in parallel and resolve with the results array, preserved in input order. If any mapper rejects, the returned promise rejects with that reason — same fast-fail behaviour as Promise.all.
// Returns a Promise that fulfils with results[] in input order.
// Rejects on the first mapper rejection (or synchronous throw).
function mapAsync<T, U>(
items: Iterable<T>,
asyncMapper: (item: T, index: number) => Promise<U> | U,
): Promise<U[]>;
// Basic parallel fetch. All three requests fire at once;
// the result settles when the slowest one finishes.
const ids = [1, 2, 3];
const users = await mapAsync(ids, (id) => fetch(`/api/users/${id}`).then((r) => r.json()));
// users === [{ id: 1, ... }, { id: 2, ... }, { id: 3, ... }]
// Errors propagate. The first rejection wins; results from later
// successful mappers are discarded.
await mapAsync([1, 2, 3], async (n) => {
if (n === 2) throw new Error('boom');
return n * 10;
}); // throws Error('boom')
// Empty input resolves to an empty array on the next microtask.
await mapAsync([], async (x) => x); // []
results[i] must hold the value asyncMapper(items[i], i) resolved to — even when that mapper finishes last.(item, index). Same shape as Array.prototype.map. The third array argument is not required.item.foo on undefined), wrap that into a rejection — don't let it escape to the caller.42 directly should work the same as one that returns Promise.resolve(42). Wrap with Promise.resolve so the code path is uniform.Sets — anything with a Symbol.iterator.You'll write the async analogue of Array.prototype.map: take a list of items, run an async mapper over every one of them in parallel, and resolve with the results in input order — or reject the moment any mapper fails.
You've got a list of user IDs and you need to fetch each user's profile. You could write a for loop with await inside it — that works, but every request waits for the previous one to finish before it starts. Five users at 50ms each turns into a 250ms render. The real shape of the work is parallel: fire all five requests at once, collect the results in the order you asked for, and bail out fast if any one of them fails. That's mapAsync.
mapAsync is a thin wrapper that does two jobs. First, it walks the input synchronously and calls the mapper on every item — every promise is now in flight, sharing the same starting line. Second, it hands all those promises to Promise.all, which waits for them to settle and gathers the results into one array indexed by input position. The slowest mapper sets the wall-clock; everything else completes earlier and waits.
The instinct, especially if you've used async/await heavily, is to write a for await loop:
async function mapAsyncSequential(items, asyncMapper) {
const results = [];
let index = 0;
for (const item of items) {
// Each iteration awaits before the next mapper even starts.
results.push(await asyncMapper(item, index++));
}
return results;
}
It produces the right values in the right order, and the code reads top-to-bottom. But the await on line 5 is a wait — control returns to the caller and the next iteration of the loop only resumes once the previous mapper has resolved. With five items at 50ms each, that's 250ms of wall-clock for a job that should take 50ms.
The fix isn't to add more awaits — it's to remove the only one we have. We need to kick all the mappers off before we start awaiting any of them.
function mapAsync(items, asyncMapper) {
// Array.from walks the iterable exactly once and calls the mapper on
// every entry SYNCHRONOUSLY. By the time this expression finishes,
// every mapper has been invoked and every returned promise is in
// flight — none of them are waiting on each other.
return Promise.all(
Array.from(items, (item, index) => {
try {
// Wrap with Promise.resolve so a mapper that returns a plain
// value (e.g. `(x) => x * 2`) works the same as one that
// returns a promise. No branching needed downstream.
return Promise.resolve(asyncMapper(item, index));
} catch (err) {
// A synchronous throw must become a rejection — otherwise it
// escapes Array.from and the caller sees a thrown exception
// instead of a rejected promise. Promise.all only inspects
// promises; we have to convert.
return Promise.reject(err);
}
}),
);
// Promise.all does the heavy lifting: it preserves index order,
// short-circuits on the first rejection, and resolves with the
// results array once every input has fulfilled.
}
module.exports = { mapAsync };
Three shifts from the sequential version. First, the mapper is called inside Array.from's mapping callback, not inside an awaited loop — so calls 0 through n-1 are issued in the same synchronous tick before any of them resolve. Second, Promise.all handles the gather — it writes each result into results[i] (input order, not settle order), rejects on the first failure, and resolves to [] for empty input without any special case. Third, the try/catch around asyncMapper converts a synchronous throw into a rejected promise so the caller always sees a promise rejection, never an exception that bubbles out of mapAsync itself.
Trace mapAsync([1, 2, 3], async (x) => x * 2).
Synchronous phase (t = 0). Array.from starts walking [1, 2, 3]. For each item it invokes our mapping callback, which calls asyncMapper(item, index):
item = 1, index = 0. asyncMapper(1, 0) returns a pending Promise (because the mapper is async, every return value gets wrapped). Promise.resolve(<promise>) returns that same promise.item = 2, index = 1. Same shape — another pending promise.item = 3, index = 2. Same again — third pending promise.Array.from returns the array [p0, p1, p2]. All three async mappers have been invoked; their bodies are queued as microtasks. We pass the array to Promise.all and Promise.all returns its own pending promise. The executor returns control to the caller.
Microtask queue. Each async mapper's body runs. async (x) => x * 2 with x = 1 resolves the body to 2; the promise p0 fulfils with 2. Same for p1 (fulfils with 4) and p2 (fulfils with 6).
Settlement. Promise.all sees all three input promises fulfil. It assembles the results array in input order — [2, 4, 6] — and resolves the outer promise. The caller's .then (or await) receives [2, 4, 6].
Two things to notice. The mapper was invoked exactly three times — once per item, in the synchronous walk. And the order of the result array comes from the input position, not the settle order — Promise.all writes results[i] for the i-th input regardless of which one finishes first.
for await vs parallel Promise.all. They produce the same result array but differ wildly in wall-clock. With 5 items at 50ms each, sequential takes 250ms and parallel takes ~50ms. Default to parallel; pick sequential only when you have a reason (see the next bullet).Promise.all short-circuits the aggregate — it rejects as soon as any input rejects. But the other mappers keep running to completion; you just throw their results away. If a mapper has side effects (writes a row, sends an email), those still happen. Native promises have no cancellation; if you need it, look at AbortController.for await is the right shape for those. Don't reach for mapAsync reflexively just because the items are independent in your head.throw new Error(...) outside an async body (e.g. a plain function that dereferences item.foo on undefined), Promise.all never sees a promise — the throw escapes Array.from. The try/catch converts it to a rejected promise, matching the spec'd behaviour: every failure path returns a rejected promise, never a thrown exception.Array.from([...items], cb). That walks the input twice — once for the spread, once for Array.from. A one-shot generator throws on the second pass. Array.from(items, cb) walks it exactly once.mapAsyncSequential). Use the for await shape from "A first attempt" — same return type, different timing. Useful for rate-limited APIs (await between calls implicitly throttles), ordered side effects, or "bail fast" loops over very large inputs where you'd rather not fire every request.mapAsyncLimit(items, limit, mapper)). Run at most limit mappers at once. The pattern is a small worker pool: spin up limit workers, each of which loops pulling the next item off a shared cursor until the cursor is exhausted, awaiting the mapper between pulls. Promise.all waits for every worker to finish. This is the building block behind libraries like p-limit.allSettled variant (mapAsyncSettled). Instead of fast-failing on the first rejection, wait for every mapper and return [{ status: 'fulfilled', value } | { status: 'rejected', reason }]. Swap Promise.all for Promise.allSettled and you're done. Use this when partial success is meaningful — e.g. fetching 50 dashboard widgets and rendering the ones that loaded, marking the rest as errored.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement mapAsync(items, asyncMapper) — the async cousin of Array.prototype.map. You take an iterable of items and a mapper that returns a promise. You apply the mapper to every item in parallel and resolve with the results array, preserved in input order. If any mapper rejects, the returned promise rejects with that reason — same fast-fail behaviour as Promise.all.
// Returns a Promise that fulfils with results[] in input order.
// Rejects on the first mapper rejection (or synchronous throw).
function mapAsync<T, U>(
items: Iterable<T>,
asyncMapper: (item: T, index: number) => Promise<U> | U,
): Promise<U[]>;
// Basic parallel fetch. All three requests fire at once;
// the result settles when the slowest one finishes.
const ids = [1, 2, 3];
const users = await mapAsync(ids, (id) => fetch(`/api/users/${id}`).then((r) => r.json()));
// users === [{ id: 1, ... }, { id: 2, ... }, { id: 3, ... }]
// Errors propagate. The first rejection wins; results from later
// successful mappers are discarded.
await mapAsync([1, 2, 3], async (n) => {
if (n === 2) throw new Error('boom');
return n * 10;
}); // throws Error('boom')
// Empty input resolves to an empty array on the next microtask.
await mapAsync([], async (x) => x); // []
results[i] must hold the value asyncMapper(items[i], i) resolved to — even when that mapper finishes last.(item, index). Same shape as Array.prototype.map. The third array argument is not required.item.foo on undefined), wrap that into a rejection — don't let it escape to the caller.42 directly should work the same as one that returns Promise.resolve(42). Wrap with Promise.resolve so the code path is uniform.Sets — anything with a Symbol.iterator.You'll write the async analogue of Array.prototype.map: take a list of items, run an async mapper over every one of them in parallel, and resolve with the results in input order — or reject the moment any mapper fails.
You've got a list of user IDs and you need to fetch each user's profile. You could write a for loop with await inside it — that works, but every request waits for the previous one to finish before it starts. Five users at 50ms each turns into a 250ms render. The real shape of the work is parallel: fire all five requests at once, collect the results in the order you asked for, and bail out fast if any one of them fails. That's mapAsync.
mapAsync is a thin wrapper that does two jobs. First, it walks the input synchronously and calls the mapper on every item — every promise is now in flight, sharing the same starting line. Second, it hands all those promises to Promise.all, which waits for them to settle and gathers the results into one array indexed by input position. The slowest mapper sets the wall-clock; everything else completes earlier and waits.
The instinct, especially if you've used async/await heavily, is to write a for await loop:
async function mapAsyncSequential(items, asyncMapper) {
const results = [];
let index = 0;
for (const item of items) {
// Each iteration awaits before the next mapper even starts.
results.push(await asyncMapper(item, index++));
}
return results;
}
It produces the right values in the right order, and the code reads top-to-bottom. But the await on line 5 is a wait — control returns to the caller and the next iteration of the loop only resumes once the previous mapper has resolved. With five items at 50ms each, that's 250ms of wall-clock for a job that should take 50ms.
The fix isn't to add more awaits — it's to remove the only one we have. We need to kick all the mappers off before we start awaiting any of them.
function mapAsync(items, asyncMapper) {
// Array.from walks the iterable exactly once and calls the mapper on
// every entry SYNCHRONOUSLY. By the time this expression finishes,
// every mapper has been invoked and every returned promise is in
// flight — none of them are waiting on each other.
return Promise.all(
Array.from(items, (item, index) => {
try {
// Wrap with Promise.resolve so a mapper that returns a plain
// value (e.g. `(x) => x * 2`) works the same as one that
// returns a promise. No branching needed downstream.
return Promise.resolve(asyncMapper(item, index));
} catch (err) {
// A synchronous throw must become a rejection — otherwise it
// escapes Array.from and the caller sees a thrown exception
// instead of a rejected promise. Promise.all only inspects
// promises; we have to convert.
return Promise.reject(err);
}
}),
);
// Promise.all does the heavy lifting: it preserves index order,
// short-circuits on the first rejection, and resolves with the
// results array once every input has fulfilled.
}
module.exports = { mapAsync };
Three shifts from the sequential version. First, the mapper is called inside Array.from's mapping callback, not inside an awaited loop — so calls 0 through n-1 are issued in the same synchronous tick before any of them resolve. Second, Promise.all handles the gather — it writes each result into results[i] (input order, not settle order), rejects on the first failure, and resolves to [] for empty input without any special case. Third, the try/catch around asyncMapper converts a synchronous throw into a rejected promise so the caller always sees a promise rejection, never an exception that bubbles out of mapAsync itself.
Trace mapAsync([1, 2, 3], async (x) => x * 2).
Synchronous phase (t = 0). Array.from starts walking [1, 2, 3]. For each item it invokes our mapping callback, which calls asyncMapper(item, index):
item = 1, index = 0. asyncMapper(1, 0) returns a pending Promise (because the mapper is async, every return value gets wrapped). Promise.resolve(<promise>) returns that same promise.item = 2, index = 1. Same shape — another pending promise.item = 3, index = 2. Same again — third pending promise.Array.from returns the array [p0, p1, p2]. All three async mappers have been invoked; their bodies are queued as microtasks. We pass the array to Promise.all and Promise.all returns its own pending promise. The executor returns control to the caller.
Microtask queue. Each async mapper's body runs. async (x) => x * 2 with x = 1 resolves the body to 2; the promise p0 fulfils with 2. Same for p1 (fulfils with 4) and p2 (fulfils with 6).
Settlement. Promise.all sees all three input promises fulfil. It assembles the results array in input order — [2, 4, 6] — and resolves the outer promise. The caller's .then (or await) receives [2, 4, 6].
Two things to notice. The mapper was invoked exactly three times — once per item, in the synchronous walk. And the order of the result array comes from the input position, not the settle order — Promise.all writes results[i] for the i-th input regardless of which one finishes first.
for await vs parallel Promise.all. They produce the same result array but differ wildly in wall-clock. With 5 items at 50ms each, sequential takes 250ms and parallel takes ~50ms. Default to parallel; pick sequential only when you have a reason (see the next bullet).Promise.all short-circuits the aggregate — it rejects as soon as any input rejects. But the other mappers keep running to completion; you just throw their results away. If a mapper has side effects (writes a row, sends an email), those still happen. Native promises have no cancellation; if you need it, look at AbortController.for await is the right shape for those. Don't reach for mapAsync reflexively just because the items are independent in your head.throw new Error(...) outside an async body (e.g. a plain function that dereferences item.foo on undefined), Promise.all never sees a promise — the throw escapes Array.from. The try/catch converts it to a rejected promise, matching the spec'd behaviour: every failure path returns a rejected promise, never a thrown exception.Array.from([...items], cb). That walks the input twice — once for the spread, once for Array.from. A one-shot generator throws on the second pass. Array.from(items, cb) walks it exactly once.mapAsyncSequential). Use the for await shape from "A first attempt" — same return type, different timing. Useful for rate-limited APIs (await between calls implicitly throttles), ordered side effects, or "bail fast" loops over very large inputs where you'd rather not fire every request.mapAsyncLimit(items, limit, mapper)). Run at most limit mappers at once. The pattern is a small worker pool: spin up limit workers, each of which loops pulling the next item off a shared cursor until the cursor is exhausted, awaiting the mapper between pulls. Promise.all waits for every worker to finish. This is the building block behind libraries like p-limit.allSettled variant (mapAsyncSettled). Instead of fast-failing on the first rejection, wait for every mapper and return [{ status: 'fulfilled', value } | { status: 'rejected', reason }]. Swap Promise.all for Promise.allSettled and you're done. Use this when partial success is meaningful — e.g. fetching 50 dashboard widgets and rendering the ones that loaded, marking the rest as errored.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.