Skip to content
Home JavaScript async forEach Fires 1000 Calls, Awaits Zero

async forEach Fires 1000 Calls, Awaits Zero

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Advanced JS → Topic 3 of 27
forEach ignores async callbacks—1000 payments fired at once, 700 failed silently with zero errors logged.
⚙️ Intermediate — basic JavaScript knowledge assumed
In this tutorial, you'll learn
forEach ignores async callbacks—1000 payments fired at once, 700 failed silently with zero errors logged.
  • async functions always return a Promise — even if you return a plain value like a string or number, it gets wrapped in Promise.resolve() automatically.
  • await only blocks the current async function's execution, not the JavaScript thread — the event loop continues running other code while it waits.
  • Multiple consecutive awaits on independent operations is a performance bug — use Promise.all() to run independent async operations in parallel and cut total wait time to the slowest single request.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • async/await is syntactic sugar over Promises—async functions return a Promise, await pauses function execution without blocking the thread
  • Core components: async keyword (marks function as asynchronous), await (waits for Promise resolution), Promise.all (parallel execution), try/catch (error handling)
  • Performance: 3 sequential awaits each taking 300ms = 900ms; Promise.all with same tasks = 300ms (3x faster for independent operations)
  • Production trap: Using array.forEach(async item => { await process(item) }) — forEach ignores returned Promises, runs all in parallel and never waits
  • Biggest mistake: Forgetting that fetch() only rejects on network errors, not HTTP 404/500 — must check response.ok or risk silent failures
🚨 START HERE

async/await Debug Cheat Sheet

Fast diagnostics for async issues in production JavaScript applications.
🟡

forEach not awaiting — code runs before async work finishes

Immediate ActionCheck if forEach is used with async callback
Commands
grep -n 'forEach.*async' src/**/*.js
grep -n 'await.*forEach' src/**/*.js
Fix NowReplace `arr.forEach(async x => await fn(x))` with `for (const x of arr) { await fn(x); }` or `await Promise.all(arr.map(fn))`
🟡

fetch errors not caught — silent failures

Immediate ActionCheck fetch calls for response.ok handling
Commands
grep -n 'fetch' src/**/*.js | grep -v 'response.ok'
grep -n '.catch' src/**/*.js | grep -v 'fetch'
Fix NowAdd `if (!response.ok) throw new Error(HTTP ${response.status})` after fetch. Register global unhandled rejection handler.
🟡

React component showing undefined before data loads

Immediate ActionCheck if component renders before async data is ready
Commands
grep -n 'useState.*null' src/components/*.jsx
grep -n 'await.*useEffect' src/components/*.jsx
Fix NowInitialize state with null or empty array, render loading indicator while data is null. Use `if (!data) return <Spinner />`
🟡

Promise.all fails early on first rejection — some data lost

Immediate ActionCheck if Promise.all used without error tolerance
Commands
grep -n 'Promise.all' src/**/*.js
grep -n 'Promise.allSettled' src/**/*.js
Fix NowReplace `Promise.all` with `Promise.allSettled` when you need partial results. Filter results: `results.filter(r => r.status === 'fulfilled').map(r => r.value)`
🟡

unhandledrejection event firing — process crash or silent fail

Immediate ActionCheck for missing .catch() on Promise or await in try/catch
Commands
node --unhandled-rejections=strict app.js
grep -n '.catch' src/**/*.js | wc -l
Fix NowWrap await in try/catch: `try { await asyncOp() } catch (e) { console.error(e) }`. Add global handler: `process.on('unhandledRejection', console.error)` in Node.js.
Production Incident

The Async forEach That Crashed the Payment Processor

A payment processing service used `orderIds.forEach(async id => { await processPayment(id); })` to process nightly batch payments. The script reported success after 2 seconds, but only 1/3 of payments completed. `forEach` ignored the returned Promises, fired all requests in parallel, and exited immediately without waiting.
SymptomThe batch job logged 'Processed 1000 orders' within 2 seconds, but accounting showed only ~300 payments succeeded. No errors in logs. The API rate limit was 100 requests/second, and 1000 simultaneous requests overwhelmed the payment gateway, causing 700 rejections. The forEach loop never waited for the Promises, so the script simply exited after initiating all requests—most were still pending.
AssumptionThe developer assumed forEach would await each iteration sequentially. They didn't know that forEach ignores the return value of the callback, and async functions always return a Promise. They also assumed the payment gateway could handle any request rate — it couldn't. The team had tested with 10 orders, all succeeded, but failed with 1000.
Root causeorderIds.forEach(async id => { await processPayment(id); }) — forEach calls the callback for each element but does NOT await the returned Promise. All 1000 payment requests fired simultaneously, overloading the payment gateway's rate limit. The script moved to the next line after firing all requests, logging 'Processed 1000 orders' before any payment actually completed. Errors from the rejected payments were unhandled because no .catch() was attached to the returned Promises. The team never saw the 700 errors.
Fix1. Replaced forEach with for (const id of orderIds) { await processPayment(id); } for sequential processing (2 seconds per payment = 33 minutes for 1000 orders—unacceptable). 2. Used proper batching: split orders into chunks of 50, process each chunk with Promise.all(chunk.map(processPayment)), with a 500ms delay between chunks to respect rate limits. 3. Added Promise.allSettled to capture all successes and failures without early rejection. 4. Wrapped the entire batch in try-catch and logged result counts (success/failure). 5. Added a circuit breaker to stop processing if failure rate exceeds 10%.
Key Lesson
forEach does NOT wait for async callbacks. It fires all and forgets. Use for...of with await for sequential, or Promise.all with .map for controlled concurrency.Always handle Promise rejections. Unhandled rejections crash Node.js 15+ and are silent failures in browsers.Test async code with realistic concurrency levels, not just single-item success cases.Use batching with Promise.all on chunks to balance throughput and load. Chunk size = 10-100 depending on API limits.
Production Debug Guide

Symptom → Action mapping for common async failures in production JS apps.

forEach loop runs but operations never complete — script exits earlyforEach doesn't await Promises. Replace with for (const item of arr) { await fn(item) } for sequential, or await Promise.all(arr.map(fn)) for parallel with controlled concurrency.
API calls failing silently — no errors, no datafetch() only rejects on network errors, not HTTP 4xx/5xx. Always check response.ok and throw manually: if (!res.ok) throw new Error(${res.status}). Also check for unhandled rejections: add process.on('unhandledRejection', console.error) in Node.js.
React component re-renders before data loads — flashes 'undefined'State starts as empty array/object. Set initial state to loading flag: const [data, setData] = useState(null); if (!data) return <Loader />. Await in useEffect with async function inside.
Slow page loads — multiple independent API calls are sequentialYou're awaiting each call separately: const a = await fetchA(); const b = await fetchB();. Use Promise.all([fetchA(), fetchB()]) to parallelise independent requests. For large numbers, use Promise.allSettled.
try/catch doesn't catch error from async functionYou forgot await. If you call asyncFn() without await, the Promise is returned but not awaited. The try/catch only catches sync errors. Add await: await asyncFn(). Or use .catch() on the Promise.

Every real app talks to something slow — a database, an API, a file system. If JavaScript stopped and stared at the wall waiting for each of those operations to finish, your UI would freeze, your server would stall, and your users would leave. Asynchronous programming is the reason JavaScript can handle thousands of requests without breaking a sweat, and async/await is the cleanest way to write that kind of code today.

Before async/await landed in ES2017, developers handled async operations with callbacks, then Promises. Both work, but callbacks turn into a pyramid of doom and raw Promise chains get noisy fast. async and await don't replace Promises — they're syntactic sugar that sits on top of them, letting you write async code that reads like normal top-to-bottom synchronous code, which is how your brain naturally thinks.

By the end you'll understand exactly why async/await exists, how it maps to the Promise model underneath, how to handle errors properly, and how to avoid the performance trap that catches most intermediate developers — running async operations in sequence when they could run in parallel. You'll write better, faster, more readable async code starting today.

What async and await Actually Do Under the Hood

The async keyword does one thing: it makes a function always return a Promise. That's it. If you return a plain value like 42, JavaScript quietly wraps it in Promise.resolve(42). This matters because it means every async function plugs seamlessly into the existing Promise ecosystem — you can chain .then() on an async function's return value if you ever need to.

The await keyword can only live inside an async function. It pauses that function's execution, hands control back to the event loop so other code can run, then resumes the function once the Promise it's waiting on settles. Critically, await does NOT block the thread — it blocks only that specific function's execution while the rest of your program continues running.

Think of async functions as generators with built-in Promise plumbing. The JavaScript engine essentially transforms your await expressions into .then() callbacks at compile time. This is why understanding Promises first makes async/await click immediately — you're not learning something new, you're learning a cleaner way to write what you already know.

This mental model prevents a lot of confusion. When something goes wrong with async/await, the debugging answer almost always lives in understanding the underlying Promise behavior.

io/thecodeforge/js/asyncUnderTheHood.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637
// ─── Demonstrating that async functions always return a Promise ───

async function getWelcomeMessage() {
  // We return a plain string, but the async keyword
  // automatically wraps this in Promise.resolve('Hello!')
  return 'Hello from an async function!';
}

// Proof: call it and check what we get back
const result = getWelcomeMessage();
console.log(result instanceof Promise); // true — it's always a Promise

// Use .then() on it — totally valid because it IS a Promise
result.then((message) => console.log(message));

// ─── Demonstrating what await actually does ───

function simulateNetworkDelay(ms) {
  // Returns a Promise that resolves after `ms` milliseconds
  // This mimics a real API call or database query
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchUserProfile() {
  console.log('1. fetchUserProfile started');

  // await pauses THIS function but NOT the whole program.
  // The event loop is free to run other code during this wait.
  await simulateNetworkDelay(1000);

  console.log('3. fetchUserProfile resumed after 1 second');
  return { id: 42, name: 'Sarah Connor', role: 'engineer' };
}

console.log('0. Before calling fetchUserProfile');
fetchUserProfile().then((user) => console.log('4. Got user:', user.name));
console.log('2. After calling fetchUserProfile — proof the thread is NOT blocked');
🔥Key Mental Model:
async/await is compiled Promise chains. Every await X is equivalent to .then(() => X). Knowing this means you can debug any async/await problem by mentally translating it back into Promise syntax.
📊 Production Insight
async/await does NOT make code run faster. It frees the thread to do other work during I/O waits, improving concurrency.
The event loop runs other tasks while async function is suspended at await. This is how Node.js achieves high concurrency with one thread.
Rule: Use async/await for I/O-bound operations (network, file, database). For CPU-bound work, use Worker Threads or offload to separate process.
🎯 Key Takeaway
async functions always return a Promise — even if you return a plain value like a string or number, it gets wrapped in Promise.resolve() automatically.
await only blocks the current async function's execution, not the JavaScript thread — the event loop continues running other code while it waits.
Rule: Every await is a .then() under the hood. Understanding Promises helps debug async/await issues.
to await or not to await
IfYou need the result of the Promise before continuing
UseUse await. The function will pause at that line until the Promise resolves, then continue with the value.
IfYou don't need the result (fire-and-forget), but must handle errors
UseCall the async function without await, but attach .catch(): doWork().catch(console.error). Use void doWork() to indicate intentional no-await.
IfYou need to run multiple independent Promises in parallel
UseUse Promise.all([async1(), async2()]) with await. This runs all concurrently, not sequentially. Total time = max of durations.
IfYou need to run Promises in sequence (each depends on previous)
UseUse separate await statements: const a = await step1(); const b = await step2(a);. Each waits for previous to complete.
IfYou need to start a Promise now but await it later
UseStore the Promise in a variable: const promise = asyncOp(); ... later await promise. The operation starts immediately, awaiting just waits for completion.

Error Handling in async/await — Do This, Not That

This is where most developers take a wrong turn. A rejected Promise inside an async function that has no error handling will give you an UnhandledPromiseRejection warning and silently swallow the error in some environments. You need a strategy — and the right strategy depends on your context.

The most readable pattern is try/catch, which lets you handle errors exactly the way you'd handle synchronous errors. This is one of the biggest wins of async/await — unified error handling between sync and async code in a single try/catch block.

But try/catch has a trap: if you need to differentiate between different kinds of failures in one block, it gets messy. In those cases, a small helper pattern — sometimes called a 'safe await' or 'to' pattern — lets you handle errors inline without a try/catch tower.

The golden rule: never use async/await without an error handling strategy. An async function that can fail and has no .catch() or try/catch is a time bomb. In production Node.js apps, unhandled Promise rejections can crash the process entirely since Node 15.

io/thecodeforge/js/asyncErrorHandling.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// ─── Pattern 1: try/catch — most readable for most situations ───

async function loadUserDashboard(userId) {
  try {
    // Both of these can throw — one try/catch covers both
    const userResponse = await fetch(`https://api.example.com/users/${userId}`);

    if (!userResponse.ok) {
      // Manually throw so we catch HTTP errors, not just network errors
      throw new Error(`HTTP ${userResponse.status}: Failed to load user ${userId}`);
    }

    const user = await userResponse.json(); // This can also throw if JSON is malformed
    console.log(`Dashboard loaded for: ${user.name}`);
    return user;

  } catch (error) {
    // One place to handle every failure in this async flow
    console.error('Dashboard failed to load:', error.message);
    return null; // Return a safe fallback so callers don't blow up
  }
}

// ─── Pattern 2: The 'safeAwait' helper — great for granular error handling ───
// Instead of nested try/catches, wrap each await to get [error, result] tuples
// Inspired by Go's error handling style

async function safeAwait(promise) {
  try {
    const data = await promise;
    return [null, data]; // [error, result] — error is null on success
  } catch (error) {
    return [error, null]; // error is populated on failure
  }
}

async function processOrder(orderId) {
  // Step 1: Validate the order
  const [validationError, order] = await safeAwait(
    fetch(`https://api.example.com/orders/${orderId}`).then((r) => r.json())
  );

  if (validationError) {
    console.error('Order validation failed:', validationError.message);
    return;
  }

  // Step 2: Charge the customer — different error, different handling
  const [paymentError, receipt] = await safeAwait(
    fetch('https://api.example.com/payments', {
      method: 'POST',
      body: JSON.stringify({ orderId: order.id, amount: order.total }),
    }).then((r) => r.json())
  );

  if (paymentError) {
    // Handle payment failure specifically — maybe alert the user differently
    console.error('Payment failed — order preserved for retry:', paymentError.message);
    return;
  }

  console.log(`Order ${orderId} confirmed. Receipt ID: ${receipt.id}`);
}

// ─── Pattern 3: Propagate errors upward intentionally ───
// Sometimes you WANT the error to bubble up to the caller

async function getProductPrice(productId) {
  const response = await fetch(`https://api.example.com/products/${productId}`);
  if (!response.ok) throw new Error(`Product ${productId} not found`);
  const product = await response.json();
  return product.price;
  // No try/catch here — the caller decides how to handle the failure
}

// The caller owns the error strategy
async function renderProductPage(productId) {
  try {
    const price = await getProductPrice(productId);
    console.log(`Price: $${price}`);
  } catch (error) {
    console.log('Could not load price. Showing "Contact for pricing" instead.');
  }
}
⚠ Watch Out:
fetch() only rejects on network failure — not on 4xx or 5xx HTTP errors. Always check response.ok or response.status after awaiting a fetch call. Forgetting this is one of the most common bugs in JavaScript apps.
📊 Production Insight
Unhandled Promise rejections cause process crashes in Node.js 15+ (exit code 1). Your app stops, no graceful shutdown.
Always catch rejections at the top level: process.on('unhandledRejection', (reason) => { console.error(reason); process.exit(1); }); in Node.js.
Rule: Every async function call should be awaited in a try/catch, or have .catch() attached. Fire-and-forget is acceptable only if errors are logged and don't affect correctness.
🎯 Key Takeaway
Always handle rejected Promises — unhandled rejections crash Node.js 15+ and are silent failures in browsers.
Use try/catch for async/await error handling — it unifies sync and async errors in one block.
Rule: fetch() does not reject on 4xx/5xx HTTP errors — always check response.ok and throw manually if needed.
async Error Handling Strategy
IfSingle async operation, need to handle errors locally
UseUse try/catch block around the await. Most readable. Exceptions caught in same function.
IfMultiple async operations, each with different error handling
UseUse safeAwait helper returning [error, result] tuples. Avoids nesting try/catch. Each operation handles its own errors inline.
IfErrors should propagate to caller (API handler, route)
UseDo NOT use try/catch. Let the exception bubble up. The caller (framework) catches and returns appropriate HTTP status.
Iffire-and-forget operation, errors should be logged but not crash
UseUse fn().catch(err => logger.error(err)). Do not use try/catch without await—it won't catch async exceptions.
Ifparallel operations with Promise.all
UseWrap Promise.all in try/catch — any rejection rejects the whole Promise.all. Use Promise.allSettled for partial tolerance.

Sequential vs Parallel Execution — The Performance Trap

This is the mistake that separates developers who understand async from those who just use it. When you write multiple await statements one after another, they run sequentially — each one waits for the previous to finish before starting. For operations that are independent of each other, this is a massive and completely unnecessary performance hit.

Imagine you need a user's profile, their recent orders, and their notification count to render a dashboard. None of those three requests depends on the others. Running them sequentially means if each takes 300ms, your dashboard takes 900ms. Running them in parallel means it takes 300ms — the time of the slowest one.

Promise.all() is your tool here. It fires all Promises at once and waits for all of them to resolve, giving you a big performance win for independent operations. Use Promise.allSettled() when you want all results regardless of whether some fail — it never rejects, it gives you an array of outcome objects.

Promise.race() resolves or rejects as soon as the first Promise settles — great for implementing timeouts. Promise.any() resolves with the first success and only rejects if all fail — useful for redundant data sources like CDN fallbacks.

io/thecodeforge/js/parallelVsSequential.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
// ─── The slow way: Sequential (bad for independent operations) ───

async function loadDashboardSlowly(userId) {
  console.time('sequential');

  // Each await WAITS for the previous one to finish.
  // Total time = 400ms + 350ms + 300ms = 1050ms
  const userProfile = await fetchUserProfile(userId);       // 400ms
  const recentOrders = await fetchRecentOrders(userId);     // 350ms  
  const notifications = await fetchNotifications(userId);   // 300ms

  console.timeEnd('sequential'); // ~1050ms
  return { userProfile, recentOrders, notifications };
}

// ─── The fast way: Parallel with Promise.all() ───

async function loadDashboardFast(userId) {
  console.time('parallel');

  // All three fetches START at the same time.
  // Total time ≈ max(400ms, 350ms, 300ms) = 400ms
  const [userProfile, recentOrders, notifications] = await Promise.all([
    fetchUserProfile(userId),    // Fires immediately
    fetchRecentOrders(userId),   // Also fires immediately
    fetchNotifications(userId),  // Also fires immediately
  ]);

  // Note: if ANY of these rejects, the whole Promise.all rejects.
  // Use Promise.allSettled if you want partial results on failure.

  console.timeEnd('parallel'); // ~400ms
  return { userProfile, recentOrders, notifications };
}

// ─── When some requests CAN fail without breaking everything ───

async function loadDashboardResilient(userId) {
  const results = await Promise.allSettled([
    fetchUserProfile(userId),
    fetchRecentOrders(userId),
    fetchNotifications(userId),
  ]);

  // allSettled never throws — each result has a status
  const [profileResult, ordersResult, notificationsResult] = results;

  const dashboard = {
    // Show user profile if loaded, otherwise show a fallback
    user: profileResult.status === 'fulfilled'
      ? profileResult.value
      : { name: 'Guest', role: 'unknown' },

    // Show orders if loaded, otherwise show empty state
    orders: ordersResult.status === 'fulfilled'
      ? ordersResult.value
      : [],

    // Notification count can silently fail — not critical
    notificationCount: notificationsResult.status === 'fulfilled'
      ? notificationsResult.value.length
      : 0,
  };

  if (ordersResult.status === 'rejected') {
    console.warn('Orders failed to load:', ordersResult.reason.message);
  }

  return dashboard;
}

// ─── Mock fetch helpers to make this runnable ───
function fetchUserProfile(userId) {
  return new Promise((resolve) =>
    setTimeout(() => resolve({ id: userId, name: 'Alex Rivera', role: 'admin' }), 400)
  );
}
function fetchRecentOrders(userId) {
  return new Promise((resolve) =>
    setTimeout(() => resolve([{ id: 'ORD-001', total: 89.99 }]), 350)
  );
}
function fetchNotifications(userId) {
  return new Promise((resolve) =>
    setTimeout(() => resolve([{ msg: 'Your order shipped' }, { msg: 'Flash sale today' }]), 300)
  );
}

// Run the fast version
loadDashboardFast(7).then((data) => {
  console.log('User:', data.userProfile.name);
  console.log('Orders:', data.recentOrders.length);
  console.log('Notifications:', data.notifications.length);
});
💡Pro Tip:
A quick way to audit your own async code: scan for multiple awaits in a row. Ask yourself 'does request B need request A's result to run?' If the answer is no, those awaits should be inside Promise.all(). This single habit can cut API-heavy page load times by 50-70%.
📊 Production Insight
Promise.all rejects immediately if any Promise rejects. This fails fast, which is good for critical operations but loses partial data.
Promise.allSettled waits for all Promises and reports each status. Use it for non-critical operations where partial data is acceptable.
Rule: For independent API calls, use Promise.all (or allSettled). For dependent calls (second needs result of first), use sequential await.
🎯 Key Takeaway
Multiple consecutive awaits on independent operations is a performance bug — use Promise.all() to run independent async operations in parallel and cut total wait time to the slowest single request.
Promise.all rejects on first failure; Promise.allSettled waits for all and reports success/failure per operation.
Rule: Audit your code for awaits in a row. If they don't depend on each other, parallelise them.
Parallel vs Sequential Decision Tree
IfOperations are independent and all must succeed for the operation to succeed
UseUse const [a, b] = await Promise.all([op1(), op2()]). Fastest, fails fast on any error.
IfOperations are independent but some may fail; we want partial results
UseUse const results = await Promise.allSettled([op1(), op2()]). Filter results.filter(r => r.status === 'fulfilled').
IfOperation B depends on result of Operation A
UseUse sequential await: const a = await op1(); const b = await op2(a);. Cannot parallelise.
IfLarge number of independent operations (100+), need concurrency control
UseUse p-limit or manual batching: split into chunks of 10, await Promise.all on each chunk, add delay between chunks.
IfNeed fastest response from multiple redundant sources (CDN fallback)
UseUse Promise.any([source1(), source2()]). Resolves with first successful Promise. Rejects only if all fail.

Real-World Pattern — Building a Resilient API Service

Knowing the mechanics is one thing. Knowing how to structure async code in a real application is another. Most production JavaScript — whether Node.js servers or React apps — wraps its async logic in service layers: plain modules that own all the fetch/database logic and export clean async functions.

This pattern keeps your components or route handlers lean. They just call await userService.getProfile(id) and handle the result. The async complexity is encapsulated in one place.

Let's also add a timeout pattern, because every real API call needs one. You can't let a slow third-party API hang your server forever. Promise.race() between your actual request and a timeout Promise is the classic solution.

Finally, adding a retry mechanism for transient failures is something every production app needs. A request that fails once due to a brief network hiccup should retry before giving up. These three ideas — service layer, timeout, and retry — are the building blocks of resilient async code.

io/thecodeforge/js/resilientApiService.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// ─── A production-quality async service with timeout and retry ───

// Utility: Rejects after `ms` milliseconds — used for timeouts
function createTimeoutPromise(ms, operationName) {
  return new Promise((_, reject) =>
    setTimeout(
      () => reject(new Error(`${operationName} timed out after ${ms}ms`)),
      ms
    )
  );
}

// Utility: Retries a Promise-returning function up to `maxAttempts` times
async function withRetry(asyncFn, maxAttempts = 3, delayMs = 500) {
  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await asyncFn(); // Try running the function
    } catch (error) {
      lastError = error;

      const isLastAttempt = attempt === maxAttempts;
      if (isLastAttempt) break;

      console.warn(`Attempt ${attempt} failed. Retrying in ${delayMs}ms...`);
      // Wait before retrying — prevents hammering a struggling server
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }

  throw lastError; // All retries exhausted — propagate the final error
}

// ─── The actual service layer ───
const WEATHER_API_TIMEOUT_MS = 5000;

async function fetchWeatherData(cityName) {
  // Race: the real request vs a timeout — whoever settles first wins
  const weatherPromise = fetch(
    `https://api.openweathermap.org/data/2.5/weather?q=${cityName}&appid=DEMO_KEY`
  ).then((response) => {
    if (!response.ok) {
      throw new Error(`Weather API error: ${response.status} for city '${cityName}'`);
    }
    return response.json();
  });

  return Promise.race([
    weatherPromise,
    createTimeoutPromise(WEATHER_API_TIMEOUT_MS, `fetchWeatherData(${cityName})`),
  ]);
}

// Public API of our weather service — this is what callers use
const weatherService = {
  async getWeather(cityName) {
    // Wrap the fetch in our retry utility — handles transient failures
    const rawData = await withRetry(
      () => fetchWeatherData(cityName),
      3,   // up to 3 attempts
      800  // 800ms between retries
    );

    // Transform the raw API response into a clean shape our app cares about
    return {
      city: rawData.name,
      country: rawData.sys.country,
      temperatureCelsius: Math.round(rawData.main.temp - 273.15),
      description: rawData.weather[0].description,
      humidity: rawData.main.humidity,
      fetchedAt: new Date().toISOString(),
    };
  },

  async getWeatherForMultipleCities(cityNames) {
    // Fetch all cities in parallel — don't make users wait for each one
    const weatherResults = await Promise.allSettled(
      cityNames.map((city) => this.getWeather(city))
    );

    return weatherResults.map((result, index) => ({
      city: cityNames[index],
      success: result.status === 'fulfilled',
      data: result.status === 'fulfilled' ? result.value : null,
      error: result.status === 'rejected' ? result.reason.message : null,
    }));
  },
};

// ─── Usage — clean and readable because the complexity is hidden ───
async function renderWeatherDashboard() {
  const cities = ['London', 'Tokyo', 'New York'];

  console.log(`Fetching weather for: ${cities.join(', ')}`);

  const results = await weatherService.getWeatherForMultipleCities(cities);

  results.forEach(({ city, success, data, error }) => {
    if (success) {
      console.log(`${city}: ${data.temperatureCelsius}°C, ${data.description}`);
    } else {
      console.log(`${city}: Failed to load — ${error}`);
    }
  });
}

renderWeatherDashboard();
🔥Architecture Note:
The withRetry and createTimeoutPromise utilities here are framework-agnostic — drop them into any Node.js, React, or vanilla JavaScript project. Libraries like axios-retry do similar things, but knowing how to build it yourself means you can customize behavior (exponential backoff, retry only on specific status codes) instead of being limited by someone else's API.
📊 Production Insight
Promises are eager — they start executing as soon as you call them, even before you await.
This means const promise = fetch(url); starts the request immediately. await promise waits for it to finish.
Rule: For Promise.all, create all Promises first (they run in parallel) then await Promise.all. Do NOT await inside the array creation.
🎯 Key Takeaway
Service layers encapsulate async complexity — retries, timeouts, error handling, and response transformation.
Promise.race with a timeout prevents hanging requests from blocking the server indefinitely.
Rule: For production APIs, implement timeout, retry, and circuit breaker patterns. Never trust external services to respond quickly or correctly.
Async Service Design Patterns
IfAPI client (fetch wrapper) for external service
UseEncapsulate fetch logic, response.ok check, and JSON parsing. Export async functions that return typed data. Add timeout and retry logic.
IfDatabase repository
UseExport async methods that use db client's promise API. Use connection pooling. Handle database-specific errors (duplicate key, constraint violation).
IfParallel data fetching for dashboard
UseUse Promise.allSettled with mapping from fields to fetch functions. Return partial results; UI shows fallback for failed sections.
IfRate-limited API batching
UseSplit requests into chunks (size = rate limit). Process each chunk with Promise.all, add delay between chunks using setTimeout promisified.
IfLong-polling or streaming
UseUse for loop with await inside. Not Promise.all. Recursively fetch next chunk until done. Track cancellation with AbortController.
🗂 async/await vs Promise.then vs Callbacks
Choose the right pattern based on readability, error handling, and concurrency needs.
Feature / AspectPromise .then()/.catch()async / awaitCallbacks
Readability for simple sequential flowsChains get noisy with multiple stepsReads like synchronous code — linear and cleanPyramid of doom for nested dependencies
Error handling.catch() at the end of a chaintry/catch blocks — same syntax as sync errorsEach callback must check error param; easy to miss
Debugging stack tracesStack traces often lose call context across .then()Stack traces are more accurate and readableCall stack is lost; async context difficult
Conditional async logicNested .then() chains, hard to readSimple if/else inside the async functionif/else with callbacks -> nesting
Parallel executionPromise.all() directlyawait Promise.all([...]) — cleaner syntaxManual counter tracking (let remaining = 2)
Learning curveMust understand Promise constructor and chainingRequires understanding Promises but less mental overheadLow entry, high pain for complex flows
Top-level usageWorks anywhereRequires async function wrapper (or top-level await in modules)Works anywhere
Cancellation supportAbortController + fetch signalSame — pass signal to fetch and abortManual (clearTimeout, etc.)
Adoption in 2026Legacy code, library internalsStandard for new developmentLegacy Node.js APIs only

🎯 Key Takeaways

  • async functions always return a Promise — even if you return a plain value like a string or number, it gets wrapped in Promise.resolve() automatically.
  • await only blocks the current async function's execution, not the JavaScript thread — the event loop continues running other code while it waits.
  • Multiple consecutive awaits on independent operations is a performance bug — use Promise.all() to run independent async operations in parallel and cut total wait time to the slowest single request.
  • fetch() does NOT reject on 4xx/5xx HTTP errors — you must manually check response.ok or response.status after every await fetch(...) call, or you'll silently miss server errors.
  • forEach does NOT await async callbacks — it fires all in parallel and moves on. Use for...of with await for sequential, or Promise.all with .map for parallel with completion tracking.

⚠ Common Mistakes to Avoid

    Using forEach with async callbacks expecting sequential execution
    Symptom

    forEach runs all iterations in parallel, the outer code continues before any async work finishes. No errors, just missing data. The loop may finish before any callback completes.

    Fix

    Use for (const item of items) { await fn(item); } for sequential execution. Use await Promise.all(items.map(fn)) for parallel with concurrency control.

    Forgetting to check response.ok in fetch calls
    Symptom

    fetch resolves even on HTTP 404, 500. The code proceeds with response.json() which may reject with invalid JSON (empty body) or succeed with error body. No error is raised for the bad status.

    Fix

    Always add if (!response.ok) throw new Error(HTTP ${response.status}) after fetch. Or use a wrapper function that does this automatically.

    Unhandled Promise rejections crashing Node.js
    Symptom

    Node.js 15+ exits with code 1 when a Promise rejection is not handled. In browsers, the rejection is silent in the console but doesn't crash.

    Fix

    Add global handler: process.on('unhandledRejection', console.error) in Node.js. In code, wrap every await in try/catch or attach .catch() to every Promise chain that may reject.

    Using Promise.all when some operations can fail and you need partial results
    Symptom

    Promise.all rejects immediately on first failure, losing all other successful results. Components that could render partially instead show nothing.

    Fix

    Use Promise.allSettled instead. It waits for all Promises and provides status for each: const results = await Promise.allSettled(promises); then filter results.filter(r => r.status === 'fulfilled').map(r => r.value).

    Awaiting inside the Promise.all array creation
    Symptom

    await Promise.all([await slowOp1(), slowOp2()]) — the second await inside the array causes sequential execution because slowOp1 is awaited before the array is created.

    Fix

    Create Promises without awaiting: const p1 = slowOp1(); const p2 = slowOp2(); await Promise.all([p1, p2]);. The operations start immediately, then you wait for all to complete.

Interview Questions on This Topic

  • QWhat is the difference between Promise.all() and Promise.allSettled()? When would you choose one over the other?Mid-levelReveal
    Promise.all() resolves when all input Promises resolve, or rejects immediately when any Promise rejects (fail-fast). It returns an array of resolved values in order. Promise.allSettled() waits for all Promises to settle (resolve or reject) and never rejects — it returns an array of objects each with status: 'fulfilled' or 'rejected' and value or reason. Choose Promise.all when all operations must succeed for the result to be useful (e.g., loading critical user data before rendering page). Choose Promise.allSettled when partial results are acceptable, and you want to display success for some and errors for others (e.g., dashboard with multiple widgets each fetching independently). allSettled is better for resilience; all is better for atomic operations.
  • QIf you have three independent API calls in an async function and you write three consecutive await statements, what's the performance problem — and how would you fix it?Mid-levelReveal
    Consecutive awaits run the API calls sequentially. Total time = sum of individual durations (e.g., 300ms + 200ms + 400ms = 900ms). The fix: use Promise.all to run them in parallel: const [res1, res2, res3] = await Promise.all([api1(), api2(), api3()]). Total time = max of individual durations (e.g., 400ms). This assumes the calls are independent (no output of one used as input to another). For dependent calls, sequential is necessary. The performance impact grows linearly: for 10 calls each 300ms, sequential = 3 seconds, parallel = 300ms (10x faster). In production dashboards, this is the difference between a fast and a slow page.
  • QCan you use await outside of an async function? What happens if you try, and is there any context where top-level await is valid?SeniorReveal
    In standard JavaScript, await can only be used inside an async function. Using it outside results in a SyntaxError. However, ES2022 introduced top-level await in ES modules. In a module (file with type="module" or .mjs extension), you can use await at the top level without wrapping in an async function. Top-level await is useful for module initialization that depends on async operations (e.g., loading config from a file, connecting to database). Node.js supports top-level await in ES modules. CommonJS scripts (require) do not support top-level await. In browsers, top-level await works in <script type="module">. The module's execution waits for the top-level await before other modules depend on it.
  • QWhy does the following code not work as expected? [1, 2, 3].forEach(async (num) => { await delay(1000); console.log(num); }); console.log('Done');Mid-levelReveal
    forEach calls the async callback for each element but does NOT await the returned Promises. The loop fires all three callbacks immediately, and the code proceeds to console.log('Done') without waiting for any of the delays. The delays run in parallel (all three start at the same time), not sequentially. After 1 second, the numbers 1, 2, 3 appear roughly simultaneously, but 'Done' appears before them. To fix: replace forEach with for (const num of [1,2,3]) { await delay(1000); console.log(num); } for sequential execution. For parallel execution with awaiting completion, use await Promise.all([1,2,3].map(num => delay(1000).then(() => console.log(num)))); — then 'Done' logs after all numbers.

Frequently Asked Questions

Can I use async await with forEach in JavaScript?

Technically yes, but it almost certainly won't do what you expect. forEach ignores returned Promises, so await inside a forEach callback doesn't pause the outer function — all iterations fire at once and your code moves on before any finish. Use a for...of loop with await for sequential processing, or Promise.all() with .map() for parallel processing.

What's the difference between async await and Promises in JavaScript?

async/await is built directly on top of Promises — it doesn't replace them. async/await is syntactic sugar that lets you write Promise-based code in a linear, synchronous-looking style. Under the hood, every await is a .then() call. You still need to understand Promises to use async/await effectively, especially for parallel patterns like Promise.all(). The advantage is readability and error handling with try/catch.

Does await block the entire JavaScript program?

No — this is a crucial distinction. await pauses only the specific async function it's inside. The JavaScript event loop keeps running, handling other callbacks, timers, and events while your async function waits. This is what makes Node.js able to handle thousands of concurrent requests despite being single-threaded. The thread is not blocked — it just moves on to other tasks and returns to the async function when the awaited Promise settles.

How do I cancel an async operation in JavaScript?

Use AbortController with fetch or custom Promises. Create a controller: const controller = new AbortController();. Pass signal to fetch: fetch(url, { signal: controller.signal }). Cancel: controller.abort(). For custom Promises, check signal.aborted and call reject(new DOMException('Aborted', 'AbortError')). AbortController works with fetch, addEventListener, and any Promise that implements AbortSignal. You cannot cancel a Promise arbitrarily — only operations that support abort (like fetch) can be stopped.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousPromises in JavaScriptNext →Event Loop in JavaScript
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged