Ch. 1 · JavaScript

Async/Await in Loops: forEach vs for...of vs Promise.all

Why await inside forEach does not wait, how for...of runs tasks in sequence, and how to run async work in parallel with a concurrency limit.

~7 min readintermediateupdated Oct 6, 2026

“Why does this code log ‘done’ before any of the files are saved?” The interviewer then shows a forEach with an async callback and an await inside it. It is one of the most common async questions in JavaScript interviews because the bug is so easy to write and so confusing to debug: nothing throws, the code looks sequential, and the results arrive later than expected or not at all.

The question opens a broader conversation about sequential versus parallel execution, error handling across many promises, and how to stop a batch job from opening 10,000 network connections at once. Interviewers want to hear that you can pick the right loop on purpose.

Before you start

You need to know that an async function always returns a promise, that await pauses only the function it appears in, and how Promise.all behaves when one input rejects. A rough idea of the event loop (synchronous code finishes before promise callbacks run) makes the timing below easier to follow.

The short answer

forEach calls your callback and throws away its return value, so the promises returned by an async callback are never awaited and the outer function carries on immediately. Use for...of with await when tasks must run one after another, and Promise.all(items.map(async ...)) when they can all run at once. When “all at once” is too many, process the items through a fixed number of workers so only N are in flight at a time.

How it works

await suspends the nearest enclosing async function. Inside forEach, the nearest async function is the callback, not your outer function. Each callback pauses on its own, forEach has already moved on to the next element, and once all callbacks have started, forEach returns undefined.

const sleep = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
const log = [];

async function saveAll(ids) {
  ids.forEach(async (id) => {
    await sleep(10);
    log.push(`saved ${id}`);
  });
  log.push('done');
}

await saveAll([1, 2, 3]);
console.log(log); // [ 'done' ]
await sleep(30);
console.log(log); // [ 'done', 'saved 1', 'saved 2', 'saved 3' ]
JavaScript

A for...of loop is different because the loop body belongs to the outer function. Each await pauses the whole function, so the next iteration only starts after the previous one settles. Promise.all takes the opposite approach: map starts every task synchronously, producing an array of pending promises, and Promise.all waits for all of them.

The same trap applies to map, filter, some and reduce. items.filter(async (x) => ...) keeps every element, because every callback returns a promise, and promises are truthy.

const nums = [1, 2, 3, 4];
const evens = nums.filter(async (n) => n % 2 === 0);
console.log(evens); // [ 1, 2, 3, 4 ]
JavaScript

Step-by-step walkthrough

Step 1: Decide whether order matters

Ask whether each task depends on the previous one. Applying database migrations, paginating with a cursor from the previous page, or writing to a rate-limited API that bans bursts all need sequential execution. Fetching independent user profiles does not. This one question tells you which loop to reach for, and interviewers like hearing it asked before any code is written.

Step 2: Sequential with for…of

When order matters, for...of with await is the clearest tool. Total time is the sum of each task’s duration. A thrown error or rejected promise stops the loop at that item, which is often what you want for migrations.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const order = [];
const start = Date.now();
for (const id of [1, 2, 3]) {
  await sleep(50);
  order.push(id);
}
const elapsed = Date.now() - start;
console.log(order); // [ 1, 2, 3 ]
console.log(elapsed >= 150); // true
JavaScript

Step 3: Parallel with map and Promise.all

When tasks are independent, start them all and wait once. Total time is roughly the slowest task. Results come back in input order even if tasks finish in a different order, because Promise.all places each result at its input index.

const sleep = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms));
const start = Date.now();
const results = await Promise.all(
  [30, 10, 20].map((ms) => sleep(ms, `took ${ms}`)),
);
console.log(results); // [ 'took 30', 'took 10', 'took 20' ]
console.log(Date.now() - start < 60); // true
JavaScript

If one task rejects, Promise.all rejects immediately with that reason, but the other tasks keep running in the background; promises cannot be cancelled. Use Promise.allSettled when you need every outcome.

Step 4: Bounded concurrency with a worker pool

Firing 5,000 requests at once can exhaust sockets, trip rate limits or overload the server. A worker pool starts N async workers that each pull the next index from a shared counter. Because JavaScript runs one piece of code at a time, next++ cannot race.

async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const index = next++;
      results[index] = await fn(items[index], index);
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let active = 0;
let peak = 0;
const out = await mapLimit([1, 2, 3, 4, 5, 6, 7], 3, async (n) => {
  active++;
  peak = Math.max(peak, active);
  await sleep(10);
  active--;
  return n * 2;
});
console.log(out.join(" ")); // 2 4 6 8 10 12 14
console.log(peak); // 3
JavaScript

Worked scenario

A nightly job sends receipt emails. The original code used forEach, so the function returned immediately, the process exited before most sends finished, and failures surfaced as unhandled rejections with no record of which customer was affected.

const sent = [];
async function sendEmail(user) {
  await new Promise((r) => setTimeout(r, 5));
  if (user === 'bad@example.com') throw new Error(`bounce: ${user}`);
  sent.push(user);
}
process.on('unhandledRejection', (e) => console.log('unhandled:', e.message));

async function runBroken(users) {
  users.forEach(async (u) => { await sendEmail(u); });
  return 'finished';
}
console.log(await runBroken(['a@example.com', 'bad@example.com']));
// finished
console.log(sent.length); // 0
await new Promise((r) => setTimeout(r, 20));
// unhandled: bounce: bad@example.com
JavaScript

The fix uses the pool from Step 4 so at most five sends run at a time, catches each failure per user so one bounce does not hide the rest, and returns a report the job can log.

async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  const worker = async () => {
    while (next < items.length) {
      const i = next++;
      results[i] = await fn(items[i]);
    }
  };
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}
async function sendEmail(user) {
  await new Promise((r) => setTimeout(r, 5));
  if (user.startsWith('bad')) throw new Error('bounce');
}
const users = ['a@example.com', 'bad@example.com', 'c@example.com'];
const report = await mapLimit(users, 5, async (u) => {
  try {
    await sendEmail(u);
    return { user: u, ok: true };
  } catch (e) {
    return { user: u, ok: false, reason: e.message };
  }
});
console.log(report.filter((r) => !r.ok));
// [ { user: 'bad@example.com', ok: false, reason: 'bounce' } ]
JavaScript

Common mistake

  • “Adding async to the forEach callback makes forEach wait.” It makes the callback return a promise, which forEach ignores.
  • “await inside map runs sequentially.” All callbacks start immediately; map returns an array of promises, not values. You still need Promise.all.
  • “Promise.all cancels the remaining tasks on failure.” It stops waiting, but the other operations continue. Use an AbortController if they must stop.
  • Using for...of for independent work. It is correct but needlessly slow: ten 200 ms requests take two seconds instead of 200 ms.
  • Async callbacks in filter. Every element passes because a promise is truthy. Resolve the predicate results first, then filter.

Verify the behavior

This script proves the timing difference between the three approaches. Run it with node loops.mjs.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const task = () => sleep(40);
const ids = [1, 2, 3, 4];

let t = Date.now();
ids.forEach(async () => { await task(); });
console.log('forEach returned after <10ms:', Date.now() - t < 10);

t = Date.now();
for (const _ of ids) await task();
console.log('for...of took >=160ms:', Date.now() - t >= 160);

t = Date.now();
await Promise.all(ids.map(task));
console.log('Promise.all took <100ms:', Date.now() - t < 100);
// forEach returned after <10ms: true
// for...of took >=160ms: true
// Promise.all took <100ms: true
JavaScript

Follow-up questions

How would you run tasks in sequence using reduce? Chain onto a promise accumulator: items.reduce((p, x) => p.then(() => task(x)), Promise.resolve()). It works, but for...of is easier to read and debug.

What does for await...of do? It awaits each value produced by an async iterable (or each promise in a sync iterable) before running the body. It suits streams and paginated APIs, not arrays of already-started promises where an early rejection may go unobserved.

How do you add a timeout per task? Race each task against a timer, or better, pass AbortSignal.timeout(ms) to APIs like fetch so the underlying work actually stops.

Is there a library for this? p-limit and p-map implement the worker-pool idea. Being able to write the 12-line version yourself is what the interview checks.

Interview exercise

You are given urls (200 items) and fetchJson(url). Write loadAll(urls) that returns results in input order, never has more than 4 requests in flight, and returns { url, error } entries for failures instead of rejecting the whole call. Explain why you did not use forEach or a plain Promise.all.

Answer and reasoning

async function loadAll(urls, fetchJson) {
  const results = new Array(urls.length);
  let next = 0;
  async function worker() {
    while (next < urls.length) {
      const i = next++;
      try {
        results[i] = { url: urls[i], data: await fetchJson(urls[i]) };
      } catch (error) {
        results[i] = { url: urls[i], error: error.message };
      }
    }
  }
  await Promise.all(Array.from({ length: Math.min(4, urls.length) }, worker));
  return results;
}
const fake = async (u) => {
  await new Promise((r) => setTimeout(r, 5));
  if (u === 'u2') throw new Error('404');
  return u.toUpperCase();
};
console.log(await loadAll(['u1', 'u2', 'u3'], fake));
// [
//   { url: 'u1', data: 'U1' },
//   { url: 'u2', error: '404' },
//   { url: 'u3', data: 'U3' }
// ]
JavaScript

forEach would return before any request finished and would leak rejections. Promise.all(urls.map(fetchJson)) would open 200 connections at once and reject on the first failure. Four workers pulling from a shared index cap concurrency at four; writing to results[i] preserves input order regardless of completion order; and the per-item try/catch turns failures into data so the caller sees every outcome.

Continue learning

Keep practising with the JavaScript interview questions and the JavaScript MCQs. Related notes: Promise.all, allSettled, race and any, async iteration and streams and composing abort signals. Official reference: MDN on for await…of.

More in JavaScript

read ✓JavaScript · mid

JavaScript Single-Flight Requests

How to share one in-flight promise between concurrent callers so a burst of identical requests makes one network call, with errors handled.

~7 min readread →
esc