Coding: execute a list of promises in series (no 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).
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
async function serial(taskFns) {
const results = [];
for (const taskFn of taskFns) {
results.push(await taskFn());
}
return results;
}Usage
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)
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
const promises = urls.map((u) => fetch(u)); // ALL start immediately — parallel!vs.
const taskFns = urls.map((u) => () => fetch(u)); // none started; serial possibleA 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:
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.all | serial | |
|---|---|---|
| Parallelism | yes (N at once) | no |
| Total time | max(t1, t2, ...) | sum(t1, t2, ...) |
| Input | promises | factories |
| Fail-fast | yes | yes (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:
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.