Back to JavaScript
JavaScript
easy
mid

How would you execute a list of Promises in series without any parallelism?

Use `reduce` to chain promises: start from `Promise.resolve([])`, await previous, run next factory, append result. Or use a `for...of` loop with `await` — cleaner. Tasks must be passed as factory functions, not pre-created promises (those already started).

3 min read·~10 min to think through

Running promises in series means each one only starts after the previous finishes. The trick: pass functions that return promises (factories), not promises themselves — pre-created promises are already running.

The cleanest version — for...of + await

js
async function serial(taskFns) {
  const results = [];
  for (const taskFn of taskFns) {
    results.push(await taskFn());
  }
  return results;
}

Usage

js
const urls = ["/a", "/b", "/c"];
const taskFns = urls.map((u) => () => fetch(u).then((r) => r.json()));
const results = await serial(taskFns);

Reduce version (older idiom)

js
function serial(taskFns) {
  return taskFns.reduce(
    (acc, taskFn) => acc.then((results) => taskFn().then((r) => [...results, r])),
    Promise.resolve([])
  );
}

Equivalent; for...of + await reads more clearly.

Why factory functions

js
const promises = urls.map((u) => fetch(u));    // ALL start immediately — parallel!

vs.

js
const taskFns = urls.map((u) => () => fetch(u));    // none started; serial possible

A common interview trap: candidates pass pre-created promises and the function runs in parallel without realizing.

Error handling

By default, an await throw rejects the whole serial promise. To continue on error:

js
async function serialSettled(taskFns) {
  const results = [];
  for (const taskFn of taskFns) {
    try {
      results.push({ status: "fulfilled", value: await taskFn() });
    } catch (reason) {
      results.push({ status: "rejected", reason });
    }
  }
  return results;
}

Comparison to Promise.all

Promise.allserial
Parallelismyes (N at once)no
Total timemax(t1, t2, ...)sum(t1, t2, ...)
Inputpromisesfactories
Fail-fastyesyes (by default)

When you want serial

  • Each task depends on the previous result (transformations).
  • Rate-limit constraints (one request per second).
  • Resource constraints (don't hammer a server, can't fan out).
  • Side effects must order deterministically (write file 1, then 2, then 3).

Usually you want parallelism; serial is the deliberate choice.

Pass-through pattern

If each task depends on the previous result, transform:

js
async function pipe(initial, fns) {
  let value = initial;
  for (const fn of fns) value = await fn(value);
  return value;
}

Common interview variants

  • "Execute these N requests one at a time" → serial.
  • "Execute these N requests with concurrency C" → see [[implement-limitconcurrencytasks-limit-using-a-worker-pool-pattern]].
  • "Chain: each step uses the previous result" → pipe.

Interview framing

"Pass factory functions, not promises — promises start when created, so a list of pre-created promises is already parallel. The cleanest implementation is a for...of loop with await: walk the factories, await each, push to results. Reduce works too but is harder to read. Default behavior fails fast on error; for 'continue and report' use a try/catch like Promise.allSettled. The serial pattern is the right choice when each task depends on the previous, when rate-limited, or when side effects must order deterministically — otherwise prefer parallelism (Promise.all) or bounded concurrency."

Follow-up questions

  • Why doesn't passing pre-created promises work for serial execution?
  • Difference between for...of+await and Promise.all?
  • How would you handle errors without failing fast?
  • How does this relate to the worker-pool concurrency pattern?

Common mistakes

  • Passing pre-created promises — they run in parallel.
  • Using forEach with async — fires off all calls in parallel.
  • Reduce version with `then` chains hard to read.
  • No error handling — first failure kills the chain.

Performance considerations

  • Serial is the slowest by design — sum of latencies. Choose deliberately; parallel is the default unless there's a reason.

Edge cases

  • Empty tasks array → returns [].
  • One task throws — default fail-fast.
  • Task returns a non-promise — await handles it (wraps in Promise.resolve).

Real-world examples

  • Database migrations that must apply in order.
  • Rate-limited API client.
  • Step-by-step deploy pipelines.

Senior engineer discussion

Seniors pass factory functions, prefer for...of+await for readability, handle errors deliberately, and reach for serial only when ordering or rate-limit actually requires it.

Related questions