async forEach Fires 1000 Calls, Awaits Zero
forEach ignores async callbacks—1000 payments fired at once, 700 failed silently with zero errors logged.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- 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
Imagine you order a pizza and sit by the phone waiting for it — you can't do anything else until it arrives. That's old-school synchronous code. async and await are like placing the order, going to watch TV, and trusting the doorbell will ring when the pizza's ready. Your program keeps doing useful work while it waits for slow things like network requests or file reads, and 'await' is just the doorbell — it tells JavaScript: 'pause here until this one thing is done, then carry on.'
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.
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.
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.Async Error Handling Best Practices — Try/Catch Done Right
While the previous section covered the mechanics of catching errors in async functions, this section distills the proven patterns that production code relies on. These are not theoretical — they are the result of debugging hundreds of silent failures in live applications.
Best Practice 1: Always check response.ok after fetch. The most common silent failure in JavaScript is assuming a 404 or 500 response will be caught by try/catch. It won't. fetch only rejects on network-level errors (DNS failure, connection refused). Always throw manually:
``javascript const res = await fetch(url); if (!res.ok) throw new Error(Fetch failed with status ${res.status}); ``
Best Practice 2: Wrap every await in try/catch unless you intentionally propagate. If you don't catch locally, ensure the caller has a catch. The worst state is an unhandled rejection that crashes the process.
Best Practice 3: Use Promise.allSettled for tolerant parallel operations. When one failure should not spoil the batch, allSettled gives you per-result status without throwing.
Best Practice 4: Register a global unhandled rejection handler in Node.js apps. This prevents crashes and logs unexpected rejections: ``javascript process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); // Optionally exit gracefully, but log first }); ``
Best Practice 5: Avoid mixing .catch() and try/catch on the same Promise. Pick one pattern per async block to avoid confusing control flow.
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.
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.Sequential vs Parallel Execution — Performance Comparison
To make the performance impact concrete, here is a side‑by‑side comparison of sequential (await in a for...of loop) versus parallel (Promise.all) execution for realistic workloads. The numbers assume independent operations with the given latencies.
| Scenario | Sequential | Parallel | Speedup |
|---|---|---|---|
| 3 API calls, each 300ms | 900ms | ~300ms | 3x |
| 10 API calls, each 200ms | 2,000ms | ~200ms | 10x |
| 100 DB queries, each 50ms | 5,000ms | ~50ms | 100x |
| Mixed: 300ms, 200ms, 400ms | 900ms | 400ms | 2.25x |
| 5 calls: 100ms each | 500ms | 100ms | 5x |
The pattern is clear: the more independent operations you have, the more dramatic the savings. For 100 operations, sequential takes 5 seconds; parallel finishes in 50ms — a 100x improvement.
However, there are two critical caveats: 1. Concurrency limits: Firing 100 requests in parallel may overwhelm the target API or your own network. Use batching (see the Concurrency Control section) when you have more than 10–20 operations. 2. Dependency chain: If operation B needs the result of A, you cannot parallelise. Sequential is required.
Always measure with realistic latencies — network jitter means the slowest call in a parallel batch may be an outlier, making total time longer than the average max.
console.time() or APM to measure actual latencies. The speedup is often less than theoretical due to resource contention, but still significant.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.
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.Concurrency Control with Batching — Fire-and-Forget Pitfall Fixed
When you have a large number of independent async operations (e.g., sending 1000 emails, updating 500 database records, or calling a third-party API with rate limits), neither sequential nor raw parallel execution works well:
- Sequential: Too slow — each operation waits for the previous one to finish.
- Raw Promise.all: Fires all operations at once — overwhelms downstream services, triggers rate limits, and may exhaust memory/file handles on your own server.
The solution is **batching**: split the items into chunks, process each chunk in parallel (using Promise.all on the chunk), and add a small delay between chunks to respect rate limits.
This pattern gives you the throughput of parallelism with the safety of controlled concurrency. The chunk size should be tuned to the API's rate limit (e.g., 50 requests per second means chunk size 50 with a 1-second delay between chunks). If the API has no explicit limit, start with chunk size 10 and increase gradually while monitoring error rates.
Below is a reusable batchProcess function that you can drop into any project. It accepts an array, an async processing function, the desired concurrency (chunk size), and an optional delay between chunks.
p-limit (npm) for a more feature-rich concurrency limiter, but the manual batch pattern is more transparent and easier to debug.Async Function Hoisting — The Gotcha That Wastes Hours in Debugging
You think you understand hoisting? Good. async functions hoist differently than function expressions, and it bites teams weekly. An async function declared with the function keyword is hoisted to the top of its scope — you can call it before its definition in the file. But assign that same async behavior to a const or let, and you get a ReferenceError if you access it before the assignment. This isn't academic. I've traced production outages where a developer refactored an async function declaration into an arrow function for "consistency" and the entire module broke silently. Here's why it matters: async functions return a Promise. If you accidentally invoke a hoisted version before its definition, you get undefined — not an error — and your downstream Promise.all waits forever. The fix? Never rely on hoisting for async functions. Declare them with const and place them at the top of your module. Your future self won't hate you.
Rewriting Promise Chains with async/await — Don't Copy, Refactor
Most tutorials show you how to mechanically replace .then() with await. That's not refactoring — that's search-and-replace. Real async/await rewrites fix the broken error handling and control flow you tolerated in promise chains. Here's the pattern you see daily: a .then() chain with a .catch() at the end that swallows every error into a single black hole. When you convert that to async/await, don't just wrap the whole thing in a single try/catch. That replicates the same broken behavior. Instead, scope your try/catch blocks around individual async operations that can fail independently. The production benefit? When a third-party API fails, you retry just that call — not replay an entire chain of side effects. I've seen a ticket queue spike because someone "refactored" a promise chain with three independent API calls into one huge try/catch that retried everything on any failure. Three times the latency. Three times the bills. Think about what can fail independently, and isolate your error handling there.
The Async forEach That Crashed the Payment Processor
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%.- 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.allon chunks to balance throughput and load. Chunk size = 10-100 depending on API limits.
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.grep -n 'forEach.*async' src/**/*.jsgrep -n 'await.*forEach' src/**/*.jsarr.forEach(async x => await fn(x)) with for (const x of arr) { await fn(x); } or await Promise.all(arr.map(fn))Key takeaways
Promise.resolve() automatically.Promise.all() to run independent async operations in parallel and cut total wait time to the slowest single request.Common mistakes to avoid
5 patternsUsing forEach with async callbacks expecting sequential execution
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
response.json() which may reject with invalid JSON (empty body) or succeed with error body. No error is raised for the bad status.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
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
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
await Promise.all([await slowOp1(), slowOp2()]) — the second await inside the array causes sequential execution because slowOp1 is awaited before the array is created.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
What is the difference between Promise.all() and Promise.allSettled()? When would you choose one over the other?
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.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's Advanced JS. Mark it forged?
9 min read · try the examples if you haven't