Why Your Agent Awaits Inside a Loop, and When to Parallelize
Quick answer: An agent that awaits inside a loop is running each iteration one at a time, so the total wait is the sum of every call. If the iterations are independent, gather the promises and run them together with Prom
Quick answer: An agent that awaits inside a loop is running each iteration one at a time, so the total wait is the sum of every call. If the iterations are independent, gather the promises and run them together with Promise.all (or in bounded batches). If each step depends on the previous one, or you need strict ordering and rate limits, the sequential await is correct and should stay.
Why the generated code awaits one call at a time
When an AI writes a loop that fetches users, loads records, or calls a service, the safe and obvious shape is a for...of loop with an await inside. It reads top to bottom, errors point at the exact item that failed, and there is no concurrency to reason about. That correctness is why the model reaches for it.
async function loadAll(ids) {
const results = [];
for (const id of ids) {
results.push(await fetchUser(id));
}
return results;
}
The problem is not that this is wrong. It waits properly and returns results in order. The problem is that fetchUser(id) for one id does not use the result from the id before it, yet the loop treats every call as if it had to wait. The work is independent but scheduled as a chain.
A common wrong turn is swapping this for Array.forEach with an async callback. forEach ignores the promise each callback returns, so the function resolves before any call finishes and errors vanish. That changes a slow but correct function into a broken one. Keep for...of, and decide deliberately whether the calls can overlap.
What the delay looks like
The numbers below are illustrative math with a fixed 100 ms per call, not a benchmark. They show how the two schedules scale as the list grows.
| Items | Sequential await in loop |
Promise.all |
Batches of 3 |
|---|---|---|---|
| 3 | 300 ms | ~100 ms | ~100 ms |
| 10 | 1,000 ms | ~100 ms | ~400 ms |
| 30 | 3,000 ms | ~100 ms | ~1,000 ms |
Sequential time grows as n × latency. Parallel time stays close to a single call, because the waits overlap. Batches sit between the two, taking roughly ceil(n / batchSize) × latency, which matters when a service would reject 30 simultaneous requests.
The fix: parallelize independent work
When no iteration reads another iteration's result, start the calls first and await them together.
async function loadAll(ids) {
return Promise.all(ids.map(id => fetchUser(id)));
}
Promise.all preserves input order even though the calls run at once, and it rejects on the first failure, matching the behavior most callers expect. For a long list, cap concurrency so you do not open a socket per item or trip a rate limit.
async function runInBatches(items, size, worker) {
const out = [];
for (let i = 0; i < items.length; i += size) {
out.push(...await Promise.all(
items.slice(i, i + size).map(worker)
));
}
return out;
}
This keeps the memory and open-connection count bounded while still overlapping work inside each batch.
When sequential await is the right call
Parallelism is not free, and some loops must stay serial. Keep the await inside the loop when:
- One iteration needs a value from the previous one, such as cursor-based pagination that passes the last seen id.
- You write to the same resource and ordering affects the result, like applying a list of edits in sequence.
- The target enforces a strict request rate or a single-writer rule, and firing all calls at once causes 429 responses or lock contention.
- A side effect has to happen in a defined order for the behavior to be correct.
Treating every awaited loop as a defect is its own mistake. The question to ask is whether the data dependency is real, not whether the word await appears.
Catching it in review
This pattern is easy to miss because the code works on a short list, then slows down as real data grows. During review, trace whether each loop body references a value from an earlier iteration. Independent reads are candidates for Promise.all or bounded batches; dependent writes and pagination stay serial. Powerduck resolves the surrounding callers and shows the diff in context, so it can flag the loop and let you confirm the dependency yourself, which is more useful than a generic "use async" suggestion. This fits the broader set of AI-generated code smells covered in the AI coding agent guide, where the fix always depends on the actual data flow.
Frequently asked questions
Is awaiting inside a loop always a bug? No. It is correct when iterations depend on each other or when ordering, rate limits, or single-writer rules require one call to finish before the next begins. It only wastes time on independent work.
Why not just use forEach for parallel calls? forEach does not await or collect the returned promises, so it does not run anything in parallel. It simply stops waiting and reports completion too early, dropping errors along the way.
Can Promise.all overload the server? With a large list it can, by opening many requests at once. Use bounded batches or a concurrency limit to keep the number of open connections and the request rate within what the API accepts.
Does Promise.all keep the result order? Yes. The returned array matches the input order even though the underlying calls finish at different times.
The bottom line
An awaited loop is a scheduling choice, not a flaw by itself. Separate the two cases: independent reads should overlap through Promise.all or bounded batches so latency stays near one call instead of stacking across the list; dependent steps and rate-limited writes should stay serial. Read the loop for a real data dependency before you change it, and never replace a slow correct loop with a forEach that does not wait at all.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.