skip to content

Sequential vs Parallel Awaits

await inside a loop runs requests one after another; starting the promises first and awaiting them together runs them concurrently. This is the most common async performance question in interviews and the most common real slowdown in shipped code.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

4

In JavaScript, a function loops over 10 user IDs and does `const user = await fetchUser(id)` inside the loop body, where each call takes about 200 ms. How long does the loop take overall, why, and how would you make the ten requests run concurrently?

level: juniorimportance: must knowfreq 80%

answer

  1. one at a time, by construction
  2. each iteration waits for the previous response
  3. sum of latencies versus the max
  4. start the work, then await it
  5. map to promises, then Promise.all

basics

~20 s

Awaiting inside the loop serializes the requests, so ten 200 ms calls take about 2 seconds. Start all ten promises first and await them together with Promise.all, and the whole batch takes roughly 200 ms.

solid answer

~40 s

About 2 seconds — the sum of the latencies. `await` suspends the async function until that promise settles, so iteration N+1's request is not even created until iteration N's response has arrived; the loop serializes the calls by construction. The fix is to separate *starting* the work from *waiting* for it: `const promises = ids.map(id => fetchUser(id))` issues all ten requests immediately, and `const users = await Promise.all(promises)` waits once for the slowest, so the batch costs about 200 ms — the max rather than the sum. `Promise.all` fulfils with results in input order regardless of which call finished first. Keep the sequential loop only when each call genuinely needs the previous one's result.

code

javascript · 31 lines
javascript
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const fetchUser = async (id) => {
  await sleep(200);
  return { id, name: `user-${id}` };
};

const ids = [1, 2, 3, 4, 5];

async function sequential() {
  const users = [];
  for (const id of ids) {
    users.push(await fetchUser(id));
  }
  return users;
}

async function concurrent() {
  const promises = ids.map((id) => fetchUser(id));
  return Promise.all(promises);
}

async function time(label, fn) {
  const start = Date.now();
  const users = await fn();
  console.log(label, Date.now() - start, 'ms', users.length, 'users');
}

(async () => {
  await time('sequential', sequential); // ~1000 ms
  await time('concurrent', concurrent); // ~200 ms
})();

go deeper

for a junior

Be ready to spot an await sitting inside a loop over independent items and say plainly that it makes the calls run one after another. Know the standard fix: map the collection to promises, then await Promise.all.

for a middle

Explain the mechanics: await suspends the function and resumes it as a continuation, so the next call is never issued until the previous promise settles. Be able to state that total time goes from the sum of latencies to the max, and that Promise.all preserves input order.

for a senior

Show how you would detect this in a running system — request duration scaling linearly with item count, a staircase in the network waterfall — and how you would verify the fix with timings rather than by eye. Mention that fan-out has to stay bounded against a rate-limited dependency.

for a principal

Frame it as a latency-budget decision: which parts of a request graph must be serial, what the fan-out costs the downstream service, and whether a single bulk endpoint beats any client-side parallelism at all. Own the guidance that stops the team applying the refactor blindly.

## What `await` does to a loop An async function is not a thread. When execution reaches `await`, the function returns control to its caller and registers the rest of its body as a continuation that runs once the awaited promise settles. Inside a `for...of` loop, that means the loop cannot advance past the `await` until that promise has settled — so the call for the second id is not made until the response for the first has arrived. The requests are serialized not by a lock and not by JavaScript being single-threaded, but purely by the order you wrote. ```js for (const id of ids) { const user = await fetchUser(id); // request N+1 starts only after response N results.push(user); } ``` ## Sum versus max With ten calls of 200 ms each, the sequential loop costs roughly 10 x 200 ms = 2 seconds. Run concurrently, the same ten calls cost roughly 200 ms — the duration of the slowest one. That gap exists because network and disk latency is mostly *waiting*, not computing: while a request is in flight the JavaScript thread is idle and could have issued the other nine. Single-threadedness limits how much CPU work can overlap; it does not limit how many I/O operations can be outstanding at once. ## Separating starting from waiting Promises in JavaScript are eager: `fetchUser(id)` begins its work the instant it is called, not when someone awaits it. So the fix is to call everything first and await afterwards. ```js const promises = ids.map((id) => fetchUser(id)); // all ten now in flight const users = await Promise.all(promises); // one wait for all of them ``` `Promise.all` takes an iterable of promises and returns a single promise that fulfils with an array of their values, **in the order of the input**, not in completion order — so `users[0]` still corresponds to `ids[0]`. That ordering guarantee is what makes the refactor safe: you get the same array you would have built by pushing in the loop. The two steps are usually collapsed into one line: ```js const users = await Promise.all(ids.map((id) => fetchUser(id))); ``` If each item needs more than one step, make the callback itself async — every callback still runs independently, and awaits *inside* one callback sequence only that item's own steps: ```js const rows = await Promise.all( ids.map(async (id) => { const user = await fetchUser(id); // per-item sequencing is fine return { id, name: user.name }; }) ); ``` ## Errors and results Because the whole batch is a single promise, one `try`/`catch` around the `await Promise.all(...)` covers all of it. Note that `Promise.all` rejects as soon as any input rejects, while the other calls carry on running to completion in the background — nothing cancels them. ## How you notice this in real code The signature is total time that scales linearly with collection size: 10 items take 2 s, 50 items take 10 s. In a browser's network panel the requests appear as a descending staircase, each starting where the previous one ended, instead of a block of bars starting together. In a server log, the handler's duration tracks the item count almost exactly. Any of those patterns means an `await` sits inside a loop over independent work. ## The one case to leave alone If iteration N's arguments come from iteration N-1's result — walking a pagination cursor, following a chain of links, applying writes that must land in order — the sequencing is the point and the loop is correct. And when the collection is large or the dependency is a rate-limited API, the answer is a bounded number of simultaneous calls rather than an unbounded `Promise.all` over everything. Independence, not style, decides which shape you use.

  • Does the concurrent version guarantee the results come back in the same order as the input array?
    Yes. `Promise.all` fulfils with an array positionally matched to its input, so `users[2]` is the result of `ids[2]` even if that call finished last. Completion order affects nothing but timing. If you build the array yourself from `.then` callbacks instead, you lose that guarantee and have to index explicitly.
  • Why doesn't JavaScript being single-threaded prevent the ten requests from overlapping?
    The single thread executes JavaScript, not the I/O. `fetchUser` hands the request to the host — the browser's network stack or the runtime's I/O layer — and returns a promise immediately. Ten requests can be outstanding at once because none of them occupies the thread while waiting; the thread only runs the continuations as responses arrive.
  • If two of the ten calls reject, what does the `Promise.all` version do?
    It rejects with the first rejection reason, and does so as soon as that first rejection happens rather than waiting for the rest. The other eight calls keep running — nothing is cancelled — and a second rejection is simply ignored because the combined promise has already settled. Wrap the `await` in try/catch to handle it.

The loop is ordering dinner one dish at a time and waiting for each plate before naming the next; Promise.all puts the whole table's order in at once and waits for the last plate to land.

saying these in an interview costs you the question

  • await is non-blocking, so the loop already runs in parallel
  • JavaScript is single-threaded, so requests can never overlap
  • Promise.all creates threads or workers to speed things up
  • forEach with an async callback waits for each item
  • Promise.all returns results in completion order

context

open as a page

In JavaScript, does `const a = await getA(); const b = await getB();` take the same total time as `const pa = getA(); const pb = getB(); const a = await pa; const b = await pb;`? Explain what makes them differ.

level: middleimportance: must knowfreq 66%

basics

~20 s

No. Calling getA() and getB() starts both operations immediately, so the second version overlaps them and costs about the slower one. Awaiting each call inline starts the second only after the first has settled, so it costs the sum.

open as a page

In JavaScript, `const results = items.map(async (item) => save(item));` gives an array of pending promises rather than saved results, and `items.forEach(async (item) => { await save(item); });` returns before anything has been saved. Explain both behaviours and give the correct way to save every item.

level: middleimportance: should knowfreq 55%

basics

~20 s

An async callback always returns a promise, so map collects promises and you must await them with Promise.all. forEach discards whatever its callback returns, so nothing waits for the saves and any rejection becomes unhandled. Use await Promise.all(items.map(...)).

open as a page

A reviewer tells you to replace every `await` inside a loop with a `Promise.all` over the whole collection. When is that refactor wrong or dangerous, and how do you decide case by case?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Only when the iterations are independent. The refactor breaks genuine data dependencies and order-sensitive side effects, and an unbounded fan-out over a large collection can exhaust connections or trip rate limits — cases where sequential or capped execution is the correct shape.

open as a page