mapSeries Async
Map one async iteratee at a time, preserve output order, fail fast on the first rejection, and contrast this with mapLimit worker pools.
- javascript
- async
- arrays
- interview
items.map(async (item) => iteratee(item)) is not mapSeries. map is synchronous. Every async callback runs now, every iteratee starts now, and you have unbounded parallel with a series-shaped spelling. await Promise.all on that result still waits in parallel. The other miss is slicing into batches of 1 and calling that a pool. One-at-a-time is a for loop with await, not a chopped Promise.all.
mapSeries maps each item through an async iteratee with one call in flight. The next index starts only after the current call fulfills. Results stay aligned to input index. The first rejection fails the returned promise and later indexes never start. Empty input fulfills with [].
Key takeaways
- One iteratee in flight. Index
i + 1starts after indexifulfills.- Output order matches the input.
pushis safe here because only one slot writes at a time.- First iteratee rejection fails the map. Later items are not called.
[]fulfills[]. No workers, no hang.- This is mapLimit with cap
1, written as a loop. Do not invent batches.
Map one async iteratee at a time, preserve order, fail fast
Treat the input as an array of values and a function (item, index, list) => thenable | value. Call that function for index 0, await it, store the value, then index 1, and so on. Thunks of no arguments are the series runner prompt. This prompt maps items.
| Input | Output |
|---|---|
[] |
fulfills [] |
| every iteratee fulfills | array of values, same order as items |
iteratee at index i rejects or throws |
reject with that reason; indexes after i never start |
| iteratee returns a thenable | adopt it |
| iteratee throws synchronously | rejection, same as a thrown async function |
async function mapSeries(items, iteratee) {
const results = [];
for (let i = 0; i < items.length; i++) {
const value = await Promise.resolve().then(() =>
iteratee(items[i], i, items),
);
results.push(value);
}
return results;
}
const delay = (ms, value) => () =>
new Promise((resolve) => setTimeout(() => resolve(value), ms));
await mapSeries(['a', 'b'], (id) => delay(id === 'a' ? 30 : 10, id)());
// ['a', 'b'] after ~40ms, not ~30ms
Promise.resolve().then(() => iteratee(...)) does the same job as in a Promise.all mapper: thenables unwrap, and a sync throw becomes a rejection instead of blowing the caller of mapSeries before .catch can attach.
Wall clock is the sum of iteratee durations. That is the point. Independent GETs wanted parallel or a cap, not this. Migrations, write-after-write APIs, and “do not stampede this one connection” want this.
results.push is correct in series. It is wrong in a parallel map, where completion order would scramble slots. Here only one iteratee runs, so push order is index order. results[i] = value is equally fine and makes the invariant obvious if you later raise the cap.
for await...of consumes; it does not schedule the iteratee
const promises = items.map((item, i) => iteratee(item, i));
const results = [];
for await (const value of promises) {
results.push(value);
}
items.map already started every iteratee. The for await...of then waits for those promises in index order. You get order-preserving consumption of a parallel start. That is not mapSeries. Use for...of (or a C-style for) and await iteratee(...) inside the loop so start is sequential too.
for await...of is the right tool for an async iterable you do not control (a stream, a pager). For an array plus an iteratee, the honest series loop is for plus await. The async iteration page covers the protocol.
Not a chopped-batch mapLimit
mapLimit is a worker pool: at most N iteratees in flight, a free slot pulls the next unused index immediately, results still written at results[index]. N === 1 is mapSeries. N >= items.length is unbounded parallel.
What mapLimit is not: slice the array into windows of N, await Promise.all on a window, then the next window. A slow item in a window parks every free slot until that window ends. With N === 1 the wasted overlap happens to be zero, so batches of 1 accidentally match series. The moment they ask for N === 3, the batch habit under-uses the cap. Write the loop for series. Write the pool for a cap. Do not practice batches.
// Accidental series when N is 1. Wrong the moment N grows.
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;
}
If the interviewer says “at most three at a time,” stop writing mapSeries and say “pool, not batches.” If they say “one after another,” stay on this page.
Empty lists, holes, already-started promises
items.length === 0: the loop body never runs, the async function fulfills []. Do not special-case a hanging Promise.all of nothing. That hang is Promise.race([]), not this.
This implementation walks 0 .. length - 1, so a hole calls iteratee(undefined, index) and still occupies a turn. Native Array.map skips holes (k in O) and keeps a hole in the output. mapSeries that densifies holes as undefined is a different contract. Name it. If they want map-like holes, skip when !(i in items) and leave a hole in results with new Array(items.length).
Already-started promises: mapSeries(items.map(fn), (p) => p) is sequential await of work that already began. Series needs the iteratee to be the thing that starts the work. Same trap as passing live promises into a series runner of thunks.
mapSeries footguns
items.map(async ...). Parallel start. Theasynckeyword does not serializemap.for awaitover that mapped array. Sequential wait, parallel start. Order of values may look right; the overlap is still parallel.- Fail-soft by swallowing.
try/catcharound one iteratee thatcontinues is not fail-fast. Only do that if they asked to skip failures. - Mutating
itemswhile running. The cursor is an index. Do notshiftthe source from the iteratee. - Cancellation. Stopping the loop prevents later starts. The in-flight iteratee (the current
fetch) still runs unless it took anAbortSignal. Join is not cancel.
Fail-fast here is stricter than Promise.all in one way: later work never starts. Promise.all has already started everything. If you need abort of the current call, pass a signal and abort in a catch. See AbortController.
Interview answer (out loud)
“mapSeries walks the array with
forandawait, one iteratee at a time, and pushes each fulfilled value so output order matches the input. Empty input fulfills with an empty array. The first rejection fails the function and later indexes never start.Array.mapof an async function is parallel, not series.for awaitof those promises only waits in order; it does not delay start. This is mapLimit with N = 1. I would not slice into batches, because that habit is wrong as soon as the cap is greater than 1. If they already created promises, the work has already started and I will say so.”
Further reading
Related on this site
- Run async tasks in series
- mapLimit: async map with a concurrency cap
- Run async tasks in parallel
- Promise.all polyfill
- Array.map polyfill
- Promise combinators practice
- The event loop
Related guides
- Async Filter FunctionFilter an array with an async predicate: wait for every predicate, keep input order, emit a dense array, and decide fail-fast vs allSettled.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.