InterviewsVector
beginnerCommon4 min read · Updated Jul 18, 2026

Run N Async Tasks in Series

Run async tasks strictly one after another in JavaScript — the async/await loop, the reduce promise-chain, why spreads make it O(n²), and when series is correct.


The problem

Given an array of task functions (each returning a promise), run them strictly one after another — task 2 must not start until task 1 settles — and resolve with all results in order.

This is the beginner end of the concurrency-shape family (parallel, race, batches, pool), and its two learning goals are: tasks must be functions (a promise is already running — you can't sequence what's already started), and sequencing = chaining, not waiting.

The modern answer: a loop

async function runInSeries(tasks) {
  const results = [];
  for (const task of tasks) {
    results.push(await task()); // start next ONLY after previous settles
  }
  return results;
}

That's it. await inside a for...of loop is series execution — the loop body can't continue until the promise settles. A rejection anywhere stops the run and rejects runInSeries's promise with that error — usually exactly what you want for dependent steps, and if it isn't, wrap individual tasks (see below) rather than silently converting failures to null.

Note this is the one place where the lint rule no-await-in-loop is wrong: the rule exists to catch accidental serialization of independent work; here serialization is the requirement. Saying that shows you know why the rule exists rather than just obeying it.

The classic answer: reduce over a promise chain

Before async/await, the idiom was folding the tasks into one chain — and interviewers still ask for it by name:

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

The accumulator is a promise, and each fold step extends the chain: p.then(t1).then(t2)... built programmatically. Understanding why this works — reduce doesn't know about promises at all; it's just building a value that happens to be a chain — is the conceptual payoff.

One performance note that doubles as a code-review signal: the popular variant chain.then((acc) => task().then((r) => [...acc, r])) copies the whole results array on every step — O(n²) for something a push does in O(n). Harmless at n=5, real at n=10,000.

Dry run

const tasks = [
  () => delay(300, "A"),
  () => delay(100, "B"),
  () => delay(200, "C"),
];
 
await runInSeries(tasks); // ["A", "B", "C"] after ~600ms
// t=0    task A starts (B and C are functions — nothing running yet)
// t=300  A settles → B starts
// t=400  B settles → C starts
// t=600  C settles → resolve ["A", "B", "C"]
// Total = SUM of durations. Parallel would be MAX (~300ms) — that's the trade.

When series is actually correct

  • Each step needs the previous result — auth token → profile → permissions. (Then consider passing results forward explicitly: a pipeline, i.e. async pipe.)
  • Order side effects matter — migrations, ordered writes, animation steps.
  • A fragile downstream needs one-at-a-time — series is a pool with limit 1; say that sentence and you've connected the whole family.

If none of these hold, series is just slow — the follow-up "why is this taking 6 seconds?" is asking you to notice the tasks were independent all along.

Edge cases interviewers probe

  • Empty array → resolves []; both versions handle it naturally (loop never runs; reduce returns the initial promise).
  • A task rejects — default: stop and propagate (dependent steps make continuing meaningless). Collect-and-continue is a different contract: results.push(await task().catch((e) => ({ error: e }))) — outcome objects, not silent nulls that later blow up as Cannot read properties of null.
  • A task throws synchronouslyawait task() catches it (the throw happens inside the async function); the raw reduce version also routes it into the chain because the throw happens inside a then callback. Worth one sentence.
  • Non-function input — a promise in the array defeats the purpose; either wrap (typeof t === "function" ? t() : t) or throw. State the policy.

Common mistakes

  • Accepting promises instead of task functions — everything runs in parallel and the "series" is fiction. The #1 planted bug in all concurrency-shape questions.
  • [...acc, r] spreads in the reduce — quadratic.
  • forEach(async (t) => await t())forEach ignores the returned promises; all tasks fire concurrently and nothing is awaited. A classic bug worth being able to explain, not just avoid.
  • Converting rejections to console.error + null inside the utility — swallowing errors is a policy decision the caller should make.
  • Mixing up total time: series = sum, parallel = max. Interviewers ask for the numbers.

Follow-up questions

  • "Now run them with concurrency K." — series is K=1, parallel is K=∞; the general answer is the pool. This escalation is the standard interview arc.
  • "Pass each result to the next task."acc = await task(acc) — the fold becomes a pipeline; compare pipe.
  • "Why does await in a loop serialize?" — each await suspends the async function until the microtask resumes it; see the event loop.
  • "Implement it without async/await or reduce." — a recursive next(i) function; good for checking the recursion is tail-shaped and error-propagating.
  • "How would you test it?" — record start/finish timestamps per task and assert task i+1 starts after task i finishes — testing the ordering contract, not just the results.

  • Fire all tasks at once and gather results — plus what parallel really means on a single thread.

  • Batching Promises

    intermediate

    Process a large list of async tasks in fixed-size sequential batches to protect downstream services.

  • First settled task wins: build the primitive behind timeouts and fastest-source fetching.

  • Retry a failing async operation N times with backoff — a small function with big production implications.