InterviewsVector
intermediateCommon8 min read · Updated Aug 24, 2026

Run N Async Tasks: Series, Parallel, and Race

Run an array of async task functions in series, in parallel, or as a race — the implementations, why 'parallel' is really concurrency on one thread, first-settle vs first-success, timeouts, and the concurrency traps interviewers probe.


Short answer: Given an array of task functions, you control how many run at once. Series = await each in a for…of loop (total time = the sum). Parallel = Promise.all(tasks.map(t => t())) (total ≈ the slowest). Race = Promise.race(tasks.map(t => t())) (settles on the first to settle). The recurring trick: tasks must be functions — a promise is already running, so you can't sequence what already started.

This is the concurrency-shape family, and it's one of the most common async interview questions. It's part of the broader Promises guide; the escalation is always bounded concurrency — the pool and batching.

The golden rule: tasks are functions

Every version below takes tasks: (() => Promise<T>)[], not Promise<T>[]. If you accept already-running promises, "series" is fiction — everything is already in flight. Accepting functions is what lets you decide when each starts. This is the #1 planted bug across all three variants.

Series — one at a time

Run them strictly one after another; task 2 must not start until task 1 settles.

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

await inside a for…of loop is series execution — the loop body can't continue until the promise settles. This is the one place the no-await-in-loop lint rule is wrong: the rule exists to catch accidental serialization of independent work; here serialization is the requirement. Knowing why the rule exists beats blindly obeying it.

The classic reduce-over-a-chain answer

Before async/await, the idiom folded the tasks into one promise 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; each fold step extends the chain p.then(t1).then(t2)…. reduce knows nothing about promises — it's just building a value that happens to be a chain. Watch the O(n²) trap: chain.then(acc => task().then(r => [...acc, r])) copies the whole array every step; push keeps it O(n) — harmless at n=5, real at n=10,000.

When series is actually correct: each step needs the previous result (auth → profile → permissions), ordered side effects matter (migrations, ordered writes), or a fragile downstream needs one-at-a-time (series is a pool with limit 1). Otherwise series is just slow — "why is this taking 6 seconds?" is the interviewer asking you to notice the tasks were independent.

Parallel — all at once, wait for all

Start them all, wait for all, resolve with results in input order.

function runInParallel(tasks) {
  return Promise.all(tasks.map((task) => task()));
}

The sentence the interviewer actually wants: JavaScript is single-threaded, so your code never runs in parallel — but the waiting does. fetch, timers, and disk I/O happen off-thread; "parallel tasks" means N I/O operations in flight while the one JS thread handles completions one at a time. That's concurrency, and it's why this gives near-Nx speedups for I/O-bound work and roughly zero for CPU-bound work (true CPU parallelism needs Web Workers). Note that tasks.map(t => t()) is what starts everything — Promise.all only aggregates, returning results in input order regardless of completion order. Total time ≈ the slowest task.

The sequencing bug interviewers plant

// PARALLEL — both timers overlap: total ≈ 300ms
const [a, b] = await Promise.all([delay(300, "A"), delay(200, "B")]);
 
// SEQUENTIAL BY ACCIDENT — the classic await bug: total ≈ 500ms
const a = await delay(300, "A"); // nothing else is even started yet
const b = await delay(200, "B");
 
// The fix that keeps await syntax: start first, await after
const pA = delay(300, "A");
const pB = delay(200, "B"); // both running now
const a = await pA;
const b = await pB;         // total ≈ 300ms

Error handling is a contract decision

Promise.all is all-or-nothing: the first rejection rejects the aggregate (while other tasks keep running — no cancellation). If partial results are acceptable, don't hand-roll catch → null:

// ❌ null poisons downstream code, error details lost to console
tasks.map((t) => t().catch((e) => { console.error(e); return null; }))
 
// ✅ per-task outcomes, nothing swallowed
const outcomes = await Promise.allSettled(tasks.map((t) => t()));

allSettled exists precisely so utilities don't invent lossy error conventions. "All-or-nothing → all; partial tolerance → allSettled; the choice belongs to the caller" is the senior phrasing.

Predict the outputRoughly how long does run() take?
const delay = (ms, v) =>
new Promise((res) => setTimeout(() => res(v), ms));
async function run() {
const a = delay(300, "A");
const b = delay(200, "B");
console.log(await a, await b);
}
run();

Race — all at once, take the first to settle

Start them all and settle with the first task that settles — success or failure, whichever comes first.

function runInRace(tasks) {
  if (tasks.length === 0) {
    return Promise.reject(new Error("runInRace: no tasks provided"));
  }
  return Promise.race(tasks.map((task) => task()));
}

Raw Promise.race([]) pends forever — correct for the spec method, but for a utility an eternal hang is a worse contract than a loud error, so we reject explicitly. "I'm deviating from spec semantics on purpose, here's why" earns more credit than either behavior silently. (Full quirk table in Promise.race.)

First-settle vs first-success — the distinction that decides correctness

A rejection can win the race. If the fastest task fails at 100ms, the race rejects at 100ms even though a success was coming at 300ms:

runInRace([
  () => delay(100).then(() => { throw new Error("fast failure"); }),
  () => delay(300, "slow success"),
]).catch((e) => e.message); // "fast failure" — the success never got its chance

If failures should lose rather than win — redundant mirrors, cache-vs-network — the tool is Promise.any (first fulfillment; aggregate rejection only if all fail). Choosing between race and any based on the failure story is exactly what this screens for.

The real production use: timeouts and fallbacks

Racing N equivalent sources is rare; racing work against a deadline is everywhere:

const result = await runInRace([
  () => fetchFromApi(query),
  () => delay(3000).then(() => { throw new Error("Timed out after 3s"); }),
]);

Two upgrades: clear the timer when the real task wins (don't leak it), and remember losing the race doesn't cancel the fetch — the request completes and is discarded. Deadline plus cancellation is AbortSignal.timeout(ms) / AbortController; hedged-request systems (DNS resolvers, CDN failover, speculative RPCs) abort the losers explicitly on first win.

The three shapes at a glance

ShapeStartsSettlesTotal timeReach for it when
Seriesone after anotherafter the lastsum of durationssteps depend on each other / ordered side effects
Parallelall at onceafter all settleslowest taskindependent work, results all needed
Raceall at onceon the first to settlefastest to settletimeouts, whichever-wins, hedging

Series is k = 1, parallel is k = ∞; the general answer to "run at most K at once" is the concurrency pool. Say that sentence and you've connected the whole family.

Edge cases interviewers probe

  • Empty array — series/parallel resolve []; race is a policy call (we reject; state your contract).
  • A task rejects — series/parallel-all fail-fast and propagate; the collect-and-continue contract is allSettled or per-task .catch(e => ({ error: e })) — outcome objects, never silent nulls that later crash as Cannot read properties of null.
  • A task throws synchronously — inside a for…of+await, it's caught; but tasks.map(t => t()) throws before Promise.all/race exists, escaping as a sync exception. Wrap untrusted tasks with Promise.resolve().then(task).
  • Unbounded fan-outrunInParallel(tenThousandTasks) opens 10,000 connections; browsers cap ~6 per origin on HTTP/1.1 and servers rate-limit. The production answer is a pool. Naming the cap before being asked is the difference between knowing the API and having operated it.
  • CPU-bound tasks — no speedup from any shape; the fix is Workers.

Common mistakes

  • Accepting promises instead of task functions — the "series/race" becomes fiction.
  • forEach(async t => await t())forEach ignores the returned promises; all tasks fire at once and nothing is awaited.
  • [...acc, r] spreads in the reduce chain — quadratic.
  • await in a loop for independent tasks — accidental series, the most common real-world async perf bug.
  • Timeout race without clearTimeout — lingering timers keep Node processes alive and pile up under load.
  • Believing race/all cancels the losers — they don't; work continues and side effects (writes!) still happen.

Follow-up questions

  • "Now cap it at K concurrent." — the promise pool; the canonical escalation.
  • "Pass each result to the next task." — series becomes a pipeline: acc = await task(acc); compare pipe.
  • "Return the first K results instead of the first 1?" — race generalizes to a settle-counter; k = n gives parallel back.
  • "Cancel the losers / the rest on first failure." — one shared AbortController, abort() on first win/fail.
  • "Why does await in a loop serialize, and how does one thread handle 100 completions?" — suspension and the microtask queue: the event loop.
  • "Parallelize CPU work?" — Web Workers + postMessage; promises coordinate, workers compute.

Frequently asked questions

How do you run async tasks in series vs parallel in JavaScript?
Series: await each task inside a for...of loop so the next starts only after the previous settles (total time = sum of durations). Parallel: start them all with tasks.map(t => t()) and aggregate with Promise.all (total time ≈ the slowest task). The tasks must be functions, not already-running promises.
Does running tasks 'in parallel' use multiple threads?
No. JavaScript runs on one thread, so your code never executes in parallel. 'Parallel' async tasks means their I/O (network, timers, disk) overlaps off-thread while the single JS thread handles completions one at a time — concurrency, not CPU parallelism. CPU-bound work needs Web Workers.
What is the difference between racing tasks and Promise.any?
Promise.race (and a race utility) settles with the first task to settle — a fast rejection wins and rejects the whole race. Promise.any settles with the first fulfillment and only rejects (with AggregateError) if every task fails. Use race for timeouts/whichever-wins; use any when failures should lose.

  • Convert error-first callback APIs into promise-returning functions, like Node's util.promisify.

  • Batching Promises

    intermediate

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

  • Memoize API Calls

    intermediate

    Cache in-flight and resolved requests to deduplicate API calls — with cache invalidation trade-offs.

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