skip to content

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%

answer

  1. independence is the precondition
  2. cursor chains cannot be started early
  3. ordered writes must stay ordered
  4. unbounded fan-out is its own outage
  5. results order is not execution order

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.

solid answer

~50 s

The refactor is only valid when iteration N does not depend on iteration N-1. Three cases make it wrong. First, a real data dependency: walking a pagination cursor, or following a chain where each call's arguments come from the previous response — those cannot be started in advance. Second, ordered side effects: writes, appends, or state transitions that must land in the order given. Third, scale — the loop may have been acting as an accidental rate limiter, and `Promise.all` over 10,000 items opens 10,000 simultaneous calls, exhausting sockets or triggering 429s; the right answer there is a fixed concurrency ceiling, not either extreme. There is also a failure-semantics change: `Promise.all` rejects on the first failure while the remaining calls keep running, so partial-completion behaviour differs from the loop's stop-at-first-error. Decide by asking whether any argument in the body derives from an earlier result, and whether the collection size is bounded.

go deeper

for a junior

Know that the concurrent refactor is only safe when the items are independent, and that a loop whose next call needs the previous answer — like paging through results — has to stay sequential.

for a middle

Explain the two failure modes concretely: a data dependency means the next call's arguments do not exist yet, and ordered side effects mean completion order matters. Be able to say that Promise.all orders results, not execution.

for a senior

Demonstrate production judgment: reason about collection size and downstream limits, name what actually breaks under an unbounded fan-out, and describe the partial-completion state left behind when Promise.all rejects mid-batch. Recommend a capped concurrency rather than either extreme.

for a principal

Frame it as an interface question, not a loop question: whether the dependency should expose a bulk operation, what concurrency budget each caller is entitled to, and how those limits are expressed and enforced across services so no individual code review has to catch it.

## Independence is the precondition "`await` in a loop is a bug" is a useful heuristic and a bad rule. The transformation from a sequential loop to a concurrent batch is only sound when the iterations are independent of each other and the collection is small enough that starting all of it at once is acceptable. Both halves of that sentence fail in real code. ## Genuine data dependencies The clearest disqualifier is when iteration N's inputs come from iteration N-1's output. Pagination is the everyday example: ```js let cursor = undefined; const all = []; do { const page = await fetchPage(cursor); // cursor comes from the previous page all.push(...page.items); cursor = page.nextCursor; } while (cursor); ``` You cannot start page 2 before page 1 returns, because you do not yet know what to ask for. The same holds for redirect chains, tree or graph traversals where children are discovered as you go, and retry loops where the next attempt depends on the previous failure. Worth distinguishing: many *apparent* dependencies are accidental. If the loop only accumulates into an array, or reuses a variable for convenience, the iterations are independent and the accumulation can move after the batch. Read what the body actually consumes, not how it is written. ## Ordered side effects Even with independent inputs, the *effects* may need ordering. Appending lines to a file, applying a sequence of state transitions, emitting events a consumer expects in order, or writing rows whose downstream processing assumes insertion order — concurrency makes completion order nondeterministic, so any of these can silently corrupt results. `Promise.all` preserves the order of its *results array*, which is often mistaken for a guarantee about the order in which the operations ran. It is not: the calls interleave freely, and only the collected values are re-ordered to match the input. ## Scale and the accidental rate limit A loop over 20 items behaves very differently from the same loop over 20,000. Sequential execution imposes a concurrency of exactly one; replacing it with `Promise.all(items.map(...))` imposes a concurrency of `items.length`. If the collection comes from user input, a database query, or a file, that number is unbounded in practice. The observable failures are familiar: connection-pool exhaustion, ephemeral-port or file-descriptor limits, `429 Too Many Requests` from the dependency, memory spikes from thousands of in-flight response buffers, and a self-inflicted overload that takes the downstream service out for everyone. Crucially the answer here is neither the loop nor the unbounded batch — it is running a fixed number at a time, which is a distinct technique with its own tradeoffs. What matters at review time is recognising that "how many at once" is a decision the code must make explicitly once the collection is not small and fixed. ## Failure semantics change too The sequential loop stops at the first error: everything before it completed, everything after it never started. `Promise.all` rejects as soon as any input rejects, but the other operations are already running and are *not* cancelled — they will complete or fail in the background after your `catch` has run. For read-only work that difference is cosmetic. For writes it is not: after a failure you no longer know which subset was applied, so your recovery or cleanup logic must handle an indeterminate partial state, and any "undo what we did" path has to account for effects that landed after the rejection. ## A decision procedure For each awaited call in the loop, ask in order: 1. Does any argument, or any branch guarding the call, derive from a previous iteration's result? If yes, it must stay sequential. 2. Do the side effects have to land in a defined order? If yes, keep it sequential. 3. Is the collection size bounded and small — a handful of known keys, a fixed set of services? If yes, batch it with `Promise.all`. 4. Otherwise the size is unbounded: keep the work concurrent but capped, and pick the cap from the dependency's real limits, not from a round number. 5. If the operations all hit the same dependency, ask whether it offers a bulk endpoint. One request for 500 ids beats any client-side fan-out of 500 requests and removes the question entirely. ## What to say in the review The honest response to "use `Promise.all` everywhere" is that the loop encodes a claim — these steps are ordered — and the batch encodes a different one — these steps are independent and all may run at once. Whichever matches the domain is correct; the performance argument only applies once the independence claim is true.

  • How do you tell a genuine data dependency from an accidental one?
    Look at what the body actually consumes. If an argument, a URL, or a branch condition derives from the previous iteration's result — a pagination cursor, a discovered id — the dependency is genuine. If the loop only pushes into an accumulator or mutates a counter, it is accidental: hoist the calls into a batch and do the accumulation on the resulting array afterwards.
  • The collection comes from user input and can hold 50,000 items. What changes?
    The unbounded batch becomes the danger rather than the fix, since it would open 50,000 concurrent operations at once and exhaust the pool or trip rate limits. Keep the work concurrent but capped at a fixed number in flight, chosen from the dependency's real limits — and check first whether a bulk endpoint lets you replace the fan-out entirely.
  • After `Promise.all` rejects mid-batch, what state is the system in?
    Indeterminate. The first rejection settles the combined promise immediately, but the remaining operations were already started and keep running to completion or failure with nobody observing them. For writes that means an unknown subset has been applied, so recovery must be idempotent or reconcile afterwards rather than assume a clean stop.
  • Does Promise.all guarantee anything about the order the operations execute in?
    No. It guarantees only that the fulfilment array is positionally aligned with its input. The operations are started in iteration order but interleave and complete in whatever order the underlying work finishes, so anything that depends on effects landing in sequence cannot rely on it.

saying these in an interview costs you the question

  • await inside a loop is always a bug to fix
  • Promise.all guarantees the operations run in order
  • More concurrency is always faster
  • A failed Promise.all cancels the remaining operations
  • Rate limits are the dependency's problem, not the caller's

context