async forEach Fires 1000 Calls, Awaits Zero
- 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.
- 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.okor risk silent failures
async/await Debug Cheat Sheet
forEach not awaiting — code runs before async work finishes
grep -n 'forEach.*async' src/**/*.jsgrep -n 'await.*forEach' src/**/*.jsfetch errors not caught — silent failures
grep -n 'fetch' src/**/*.js | grep -v 'response.ok'grep -n '.catch' src/**/*.js | grep -v 'fetch'React component showing undefined before data loads
grep -n 'useState.*null' src/components/*.jsxgrep -n 'await.*useEffect' src/components/*.jsxPromise.all fails early on first rejection — some data lost
grep -n 'Promise.all' src/**/*.jsgrep -n 'Promise.allSettled' src/**/*.jsunhandledrejection event firing — process crash or silent fail
node --unhandled-rejections=strict app.jsgrep -n '.catch' src/**/*.js | wc -lProduction Incident
orderIds.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.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%.Promise.all on chunks to balance throughput and load. Chunk size = 10-100 depending on API limits.Production Debug GuideSymptom → Action mapping for common async failures in production JS apps.
for (const item of arr) { await fn(item) } for sequential, or await Promise.all(arr.map(fn)) for parallel with controlled concurrency.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.const [data, setData] = useState(null); if (!data) return <Loader />. Await in useEffect with async function inside.const a = await fetchA(); const b = await fetchB();. Use Promise.all([fetchA(), fetchB()]) to parallelise independent requests. For large numbers, use Promise.allSettled.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.
// ─── 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');
Promise.resolve() automatically.doWork().catch(console.error). Use void doWork() to indicate intentional no-await.Promise.all([async1(), async2()]) with await. This runs all concurrently, not sequentially. Total time = max of durations.const a = await step1(); const b = await step2(a);. Each waits for previous to complete.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.
// ─── 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.'); } }
process.on('unhandledRejection', (reason) => { console.error(reason); process.exit(1); }); in Node.js.fetch() does not reject on 4xx/5xx HTTP errors — always check response.ok and throw manually if needed.fn().catch(err => logger.error(err)). Do not use try/catch without await—it won't catch async exceptions.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.
// ─── 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); });
Promise.all(). This single habit can cut API-heavy page load times by 50-70%.Promise.all() to run independent async operations in parallel and cut total wait time to the slowest single request.const [a, b] = await Promise.all([op1(), op2()]). Fastest, fails fast on any error.const results = await Promise.allSettled([op1(), op2()]). Filter results.filter(r => r.status === 'fulfilled').const a = await op1(); const b = await op2(a);. Cannot parallelise.p-limit or manual batching: split into chunks of 10, await Promise.all on each chunk, add delay between chunks.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.
// ─── 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();
const promise = fetch(url); starts the request immediately. await promise waits for it to finish.Promise.allSettled with mapping from fields to fetch functions. Return partial results; UI shows fallback for failed sections.Promise.all, add delay between chunks using setTimeout promisified.for loop with await inside. Not Promise.all. Recursively fetch next chunk until done. Track cancellation with AbortController.| Feature / Aspect | Promise .then()/.catch() | async / await | Callbacks |
|---|---|---|---|
| Readability for simple sequential flows | Chains get noisy with multiple steps | Reads like synchronous code — linear and clean | Pyramid of doom for nested dependencies |
| Error handling | .catch() at the end of a chain | try/catch blocks — same syntax as sync errors | Each callback must check error param; easy to miss |
| Debugging stack traces | Stack traces often lose call context across .then() | Stack traces are more accurate and readable | Call stack is lost; async context difficult |
| Conditional async logic | Nested .then() chains, hard to read | Simple if/else inside the async function | if/else with callbacks -> nesting |
| Parallel execution | Promise.all() directly | await Promise.all([...]) — cleaner syntax | Manual counter tracking (let remaining = 2) |
| Learning curve | Must understand Promise constructor and chaining | Requires understanding Promises but less mental overhead | Low entry, high pain for complex flows |
| Top-level usage | Works anywhere | Requires async function wrapper (or top-level await in modules) | Works anywhere |
| Cancellation support | AbortController + fetch signal | Same — pass signal to fetch and abort | Manual (clearTimeout, etc.) |
| Adoption in 2026 | Legacy code, library internals | Standard for new development | Legacy 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
Interview Questions on This Topic
- QWhat is the difference between
Promise.all()andPromise.allSettled()? When would you choose one over the other?Mid-levelReveal - 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
- 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
- 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
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 . Pass signal to fetch: AbortController();fetch(url, { signal: controller.signal }). Cancel: . For custom Promises, check controller.abort()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.
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.