Expecting multiple calls using await to a function that returns a promise to behave synchronously but it doesn’t

Summary

This incident stems from a common misunderstanding of how async functions behave inside array iteration methods. The engineer expected await inside forEach to serialize asynchronous calls, but forEach does not await anything. As a result, all asynchronous operations fired concurrently, producing unexpected log ordering.

Root Cause

The root cause is using await inside Array.forEach, which does not respect asynchronous flow.

Key points:

  • forEach does not await the callback.
  • The callback is invoked synchronously, launching all async operations immediately.
  • await only pauses inside the callback, not the outer function.
  • The surrounding code continues executing, leading to:
    • All requests being sent at once
    • finish logging before any responses return

Why This Happens in Real Systems

Real systems frequently exhibit this behavior because:

  • Array iteration helpers (forEach, map, filter) are not async-aware
  • Engineers assume await forces sequential execution, but it only pauses the current async function
  • Event-loop scheduling means logs and callbacks interleave in unintuitive ways
  • Promises resolve independently, so completion order differs from invocation order

Real-World Impact

This misunderstanding can cause:

  • Unintentional concurrency, overwhelming downstream services
  • Race conditions, especially when order matters
  • Misleading logs, complicating debugging
  • Resource exhaustion, such as too many open connections
  • Partial failures, where some requests succeed and others fail unpredictably

Example or Code (if necessary and relevant)

Below is the correct sequential version using a for...of loop:

console.warn('start');

for (const instance of instances) {
  console.log('request sent');
  const metrics = await collectMetrics(instance);
  console.log('data received', metrics);
}

console.warn('finish');

And the correct concurrent version (if concurrency is desired):

console.warn('start');

const results = await Promise.all(
  instances.map(instance => collectMetrics(instance))
);

console.log('data received', results);
console.warn('finish');

How Senior Engineers Fix It

Experienced engineers apply patterns that avoid async pitfalls:

  • Use for...of for sequential async operations
  • Use Promise.all for controlled concurrency
  • Never use await inside forEach
  • Wrap async flows in dedicated orchestration functions
  • Add structured logging to clarify execution order
  • Document async behavior to prevent future confusion

Why Juniors Miss It

Junior engineers often miss this issue because:

  • They assume await is a global pause, not a local pause
  • They expect array helpers to be async-aware, but they are not
  • They lack experience with event-loop timing and microtask queues
  • They rely on intuition rather than understanding Promise scheduling
  • They have not yet internalized that async ≠ synchronous, even with await

This misunderstanding is extremely common, and resolving it is a key milestone in mastering asynchronous JavaScript.

Leave a Comment