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.
- 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
nonly after taskn - 1fulfills.- 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...ofplusawaitis the form you can defend.reduceof 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
- Already-started promises.
asyncSeries(tasks.map(t => t()))is parallel with extra waiting. The argument must be thunks. - Empty input. Fulfill
[]. Don’t throw. Don’t hang. - Mutating
resultsinreduce. Fine if you own the array you seeded. Don’t mutate a caller-owned list. - Sparse
tasks.for...ofvisits holes asundefined, thenundefined()throws. Either reject with a TypeError or skip holes on purpose and say so. - Cancellation. Stopping the loop prevents later starts. In-flight work (the current
fetch) still runs unless you pass anAbortSignal. 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...ofandawait, push each fulfilled value, and let the first rejection fail the function so later tasks never start.reducecan encode the same chain, but the reducer has to return the next promise; anasyncreducer 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 on this site
- Run async tasks in parallel
- mapLimit: async map with a concurrency cap
- Promise.all polyfill
- Promises
- Promise combinators practice
- Async iteration and for await…of
Related guides
- Run Async Tasks in ParallelStart every task now, wait with Promise.all, and talk about the wall-clock vs series. Error handling and why unbounded parallel is a production bug.
- Retry a Promise N TimesRetry an async function with a bounded count, delay, and a shouldRetry predicate. Do not retry 4xx you caused, and never hide the last error.
- 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.
- 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.
- Cancelable Async TasksWrap work so a caller can abort: AbortController, ignored stale results, and the difference between cancelling IO and ignoring a resolved promise.