ESC

Type to search the knowledge base.

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.

intermediate6 min read
  • 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 + 1 starts after index i fulfills.
  • Output order matches the input. push is 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

  1. items.map(async ...). Parallel start. The async keyword does not serialize map.
  2. for await over that mapped array. Sequential wait, parallel start. Order of values may look right; the overlap is still parallel.
  3. Fail-soft by swallowing. try/catch around one iteratee that continues is not fail-fast. Only do that if they asked to skip failures.
  4. Mutating items while running. The cursor is an index. Do not shift the source from the iteratee.
  5. Cancellation. Stopping the loop prevents later starts. The in-flight iteratee (the current fetch) still runs unless it took an AbortSignal. 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 for and await, 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.map of an async function is parallel, not series. for await of 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 guides