Choose execution and state ownership

Awaiting Async Work in TypeScript Loops: Choose Sequential `for...of` or Concurrent `Promise.all`

Learn when to await work inside a TypeScript loop, when to use Promise.all, and why async forEach does not make the caller wait.

Editorial illustration for Awaiting Async Work in TypeScript Loops: Choose Sequential `for...of` or Concurrent `Promise.all`

Before choosing loop syntax, ask whether one operation must finish before the next starts. If it must, put await inside a for...of loop. If the operations are independent, start them by mapping the items to promises, then await Promise.all. When all operations succeed, both approaches let the caller wait for their results, but they start the work differently. A rejection changes what the caller can conclude, particularly with Promise.all.

The examples use the same small collection and processing function. processItem is declared here to show its expected input and result; a running application needs an implementation. The types describe calls to that function—they do not make its asynchronous work finish in any particular order.

type Item = { id: string };
type Result = { id: string; status: string };

const items: Item[] = [{ id: "a" }, { id: "b" }, { id: "c" }];

declare function processItem(item: Item): Promise<Result>;

When the next item depends on the previous one

A for...of loop with await pauses the loop body until the current call completes. Only then does the next iteration call processItem. Use this shape when operations depend on earlier results or must be serialized. This is sequential waiting, not a faster spelling of concurrent work. LogRocket’s discussion of async/await describes the line-by-line behavior.

async function processSequentially(): Promise<Result[]> {
  const results: Result[] = [];

  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
  }

  return results;
}

This example collects each result before moving on. If processItem rejects, the function does not proceed to the next item; the rejection is left for its caller to handle. The important ordering comes from the await inside the loop, not from the Result[] annotation.

When every item can start independently

If each call can run without waiting for another, map the items to promises and await them together. map invokes processItem for each item and produces the promises; Promise.all provides their combined results when they all succeed. The calls can be in progress concurrently rather than one after another. LogRocket’s Promise.all example uses that distinction for independent operations.

async function processConcurrently(): Promise<Result[] | undefined> {
  try {
    const results = await Promise.all(
      items.map(item => processItem(item))
    );
    return results;
  } catch (error) {
    console.error("Could not process every item", error);
    return undefined;
  }
}

If one promise rejects, the aggregate rejects and the surrounding catch receives the failure; it does not return a complete array of successful results. Reaching this catch does not establish that the other operations have finished. They may still be unresolved when this function returns undefined. Decide whether that behavior suits the caller: this example logs the failure and returns undefined, while a caller that should handle the failure could receive the rejection instead if this function omitted its catch. If outcomes for every operation matter even after a failure, Promise.allSettled is the distinct operation that waits for them all.

Why await items.forEach(...) does not wait

An async callback does not turn forEach into an asynchronous loop. forEach invokes its callbacks but does not wait for the promises they return, and its own call does not provide a promise representing their completion. Consequently, placing await before forEach does not make the following statement wait for the item work. This is the async forEach pitfall.

async function doesNotWaitForCallbacks(): Promise<void> {
  await items.forEach(async item => {
    await processItem(item);
    console.log("finished", item.id);
  });

  console.log("after forEach"); // May run before any "finished" log.
}

The await in each callback waits within that callback. It does not give the enclosing function a way to wait for all callbacks. If completion matters to the caller, choose the sequential loop or Promise.all instead.

Check the ordering with controllable promises

A small runtime check can make the distinction observable without relying on delays. Substitute a processItem implementation that records each item when called and returns a deferred promise whose resolution or rejection you control. For processSequentially, start the function and check that only a has been called. Resolve a and allow the waiting function to resume; only then should b start. Repeat for c. The result becomes available after the last promise resolves.

For processConcurrently, check that a, b, and c have all been called before resolving any promise. Resolve them one at a time: the function should not return successful results until all three resolve. In a separate run, leave b unresolved and reject a. Check that the catch path runs while b is still pending; the rejection is not evidence that every item is done. Finally, run doesNotWaitForCallbacks with its item promises unresolved: its "after forEach" log can occur before the "finished" logs. These checks examine runtime scheduling, which the type annotations alone cannot establish.

Find a note

Search by topic, title, or keyword.