ESC

Type to search the knowledge base.

Run Async Tasks in Series

Execute async functions one after another, collect results in order, and stop on first rejection. Reduce vs for-await, and when series is the point.

intermediate5 min read
  • javascript
  • promises
  • async
  • interview

A migration script must create a user, then a billing customer, then a subscription. Someone wraps the three functions in Promise.all because “it’s async.” The subscription request fires before the user exists, the API 409s, and the whole batch retries into a mess of half-created rows.

Run async tasks in series when later work is illegal until earlier work fulfills, or when you must not stampede a shared resource. Parallelism is a different tool. The interview is whether you can wait without losing results or swallowing the first error.

The other classic miss is tasks.map((t) => t()). map is synchronous. Every thunk starts before you await anything. You get parallel start, series-shaped code, and a production incident.

  • A series runner starts task n only after task n - 1 fulfills.
  • Results stay in input order. That is cheap here, because only one task is in flight.
  • The first rejection fails the whole run. Later thunks never start.
  • for...of plus await is the form you can defend. reduce of promises is the same contract with a famous footgun.
  • If the caller already built promises, they have already started. Series needs thunks.

Each task sees the previous one finish

Treat the input as an array of functions that return a thenable (or a value). You call function i + 1 only after the promise from function i fulfills. That is the whole spec.

Input Output
[] fulfills with []
all thunks fulfill array of values, same order as tasks
thunk i rejects or throws reject with that reason; thunks after i are not called
thunk returns a thenable adopt it (Promise.resolve)
thunk throws synchronously turn into a rejection, same as a thrown async function
async function asyncSeries(tasks) {
  const results = [];
  for (const task of tasks) {
    results.push(await Promise.resolve().then(() => task()));
  }
  return results;
}

const delay =
  (ms, value) =>
  () =>
    new Promise((resolve) => setTimeout(() => resolve(value), ms));

await asyncSeries([delay(30, 'a'), delay(10, 'b')]);
// ['a', 'b'] after ~40ms, not ~30ms

Promise.resolve().then(() => task()) does two jobs: thenables are unwrapped the same way await unwraps them, and a synchronous throw becomes a rejection instead of blowing the caller of asyncSeries before it can attach .catch.

Wall clock is roughly the sum of durations. That is the point, not a bug. If you wanted the max, you wanted parallel execution.

reduce is sequential only if each step returns the next wait

Array.prototype.reduce walks the array synchronously. The reducer runs for every element before any await in those reducers can settle. Series happens only because you thread one promise through as the accumulator.

function asyncSeriesReduce(tasks) {
  return tasks.reduce(
    (chain, task) =>
      chain.then((results) =>
        Promise.resolve()
          .then(() => task())
          .then((value) => {
            results.push(value);
            return results;
          }),
      ),
    Promise.resolve([]),
  );
}

This matches the for loop: each then waits, then pushes, then hands the same array forward. Drop the return of the inner promise, or forget to use chain, and you are back to parallel.

The version people paste from muscle memory:

// Broken: reduce calls every async reducer now.
// None of them wait on `prev` before starting `task`.
tasks.reduce(async (prev, task) => {
  await task();
}, Promise.resolve());

Each reducer is an async function, so it returns a promise immediately. reduce does not await those promises. Every task() is invoked in one synchronous sweep. await prev as the first line would fix it. Relying on that is how this bug ships.

for await...of consumes; it does not schedule

for await...of pulls from an async iterator. An array of promises is iterable; the loop awaits each element in order. That is sequential consumption, not sequential start.

const promises = tasks.map((t) => t()); // all started
const results = [];
for await (const value of promises) {
  results.push(value);
}

Use for await...of when you already have an async iterable (a stream, a paginator). For an array of thunks, for...of plus await task() is the honest series loop. The async iteration page covers the protocol; this page is the orchestration choice.

Waterfall is a different prompt

Series collects independent results: task() sees no previous value. A waterfall passes the fulfilled value into the next function.

async function asyncWaterfall(tasks, seed) {
  let value = seed;
  for (const task of tasks) {
    value = await Promise.resolve().then(() => task(value));
  }
  return value;
}

await asyncWaterfall(
  [(n) => n + 1, async (n) => n * 2],
  3,
); // 8

If the interviewer says “each call gets the previous result,” write waterfall. If they say “run these in order and give me all the values,” write series. Don’t mix the APIs.

Footguns of async series

  1. Already-started promises. asyncSeries(tasks.map(t => t())) is parallel with extra waiting. The argument must be thunks.
  2. Empty input. Fulfill []. Don’t throw. Don’t hang.
  3. Mutating results in reduce. Fine if you own the array you seeded. Don’t mutate a caller-owned list.
  4. Sparse tasks. for...of visits holes as undefined, then undefined() throws. Either reject with a TypeError or skip holes on purpose and say so.
  5. Cancellation. Stopping the loop prevents later starts. In-flight work (the current fetch) still runs unless you pass an AbortSignal. Same rule as Promise.all: join is not cancel.

Series is the right default for migrations, checkout steps, and anything with a write-after-write dependency. It is the wrong default for independent GETs. Unbounded parallel is also wrong for those; cap them with mapLimit.

Interview out loud

“I need thunks, not live promises, because a created promise has already started. I walk the list with for...of and await, push each fulfilled value, and let the first rejection fail the function so later tasks never start. reduce can encode the same chain, but the reducer has to return the next promise; an async reducer that forgets to await the accumulator starts everything at once. Empty input fulfills with an empty array. If each function should receive the previous value, that is a waterfall, and I will say so before coding.”

Further reading

Related guides