Quick take: Async/await is syntax over Promises, added to JavaScript in ES2017. Every async function returns a Promise, and await pauses execution until that Promise settles, then hands back the resolved value directly. Handle failures with try/catch, run independent calls together with Promise.all instead of one at a time, and never await inside Array.forEach since it silently ignores the returned Promise and doesn't actually wait.
JavaScript async await lets you write asynchronous code that reads like synchronous code, without the nesting of .then() chains. It landed in ES2017 and is now the default way to handle Promises. An async function always returns a Promise. The await keyword pauses that function until the awaited Promise resolves or rejects, then hands you the resolved value directly. You handle errors with a normal try/catch block. You run independent operations at the same time by starting them first and passing them to Promise.all. The two mistakes that show up most in production are awaiting calls one at a time when they could run in parallel, and using await inside Array.forEach, which silently does nothing. Get those two right and async code stops being scary.
Broken async code fills more debugging sessions than any other JavaScript topic. The bugs are almost never exotic. They're the same three patterns over and over. Let's walk through them.
What Is Async/Await in JavaScript?
Here's the same fetch written both ways. Which one would you rather read at 2am during an incident?
// Promise chain
function getUser(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json())
.then(user => fetch(`/api/orders/${user.id}`))
.then(res => res.json());
}
// Async/await - same logic, flat structure
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
const orders = await fetch(`/api/orders/${user.id}`);
return orders.json();
}
Both do exactly the same thing. The second version reads top to bottom. That's the whole pitch. await only works inside an async function (or at the top level of an ES module), and it unwraps the Promise so user is the actual object, not a pending Promise.
A Promise is a JavaScript object representing the eventual result of an asynchronous operation, existing in one of three states: pending, fulfilled, or rejected. Top-level await is a feature, standardized in ES2022, that lets you use the await keyword directly in an ES module without wrapping it in an async function, useful for one-off scripts and module initialization that depends on an async resource.
How Do You Handle Errors with Async/Await?
This is where the syntax really pays off. With Promise chains you need .catch(). With async/await you use the try/catch you already know from synchronous code.
async function loadDashboard(userId) {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
return await res.json();
} catch (err) {
console.error('Dashboard load failed:', err.message);
return null;
}
}
One trap here: fetch does not reject on a 404 or 500. It only rejects on a network failure. So you have to check res.ok yourself and throw. This one regularly costs teams half a day, they assume a failed HTTP status will land in the catch block, and it never does.
How Do You Run Async Calls in Parallel?
This is the single biggest performance win people miss. Look at this:
// Slow - runs sequentially, 600ms total
const user = await fetchUser(); // 200ms
const posts = await fetchPosts(); // 200ms
const stats = await fetchStats(); // 200ms
// Fast - runs concurrently, ~200ms total
const [user, posts, stats] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchStats(),
]);
If those three calls don't depend on each other, the sequential version wastes 400 milliseconds for no reason. Promise.all starts all three immediately and resolves when the slowest one finishes. At dashboard scale the math gets dramatic: move six independent API calls from sequential awaits to a single Promise.all and an initial load of 1.4 seconds drops to 380 milliseconds. Same requests, same server, just no more waiting in line.
One caveat worth knowing: Promise.all rejects as soon as any single Promise rejects. If you need every result regardless of individual failures, use Promise.allSettled instead, it waits for all of them and reports each outcome separately.
| Sequential awaits | Promise.all | Promise.allSettled | |
|---|---|---|---|
| Timing (3 calls, 200ms each) | ~600ms | ~200ms | ~200ms |
| On one failure | Stops at that call | Rejects immediately, other results lost | Waits for all, reports each outcome |
| Dashboard example (6 calls) | 1.4 seconds initial load | 380ms initial load | Use when partial data is acceptable |
What Are the Most Common Async/Await Mistakes?
The forEach trap catches everyone once. This looks correct and is completely broken:
// BROKEN - forEach ignores the returned Promise
items.forEach(async (item) => {
await save(item);
});
console.log('done'); // logs immediately, saves are still running
Array.forEach throws away whatever the callback returns, so it never waits. The fix depends on what you want:
// Sequential - one save at a time, in order
for (const item of items) {
await save(item);
}
// Concurrent - all saves at once
await Promise.all(items.map(item => save(item)));
Use for...of when order matters or you're rate-limited. Use the Promise.all version when the operations are independent and you want speed. My rule of thumb: reach for for...of first, because a runaway Promise.all over 10,000 items will happily open 10,000 connections and take down your database. Concurrency is a tool, not a default.
Here's how to check which loop pattern a piece of async code actually needs:
- Ask if later iterations depend on earlier ones finishing first. If yes, use
for...ofwithawaitinside it. - Ask if the operations are independent and speed matters more than order. If yes, map to Promises and pass them to
Promise.all. - Ask how many items you're dealing with. Past a few hundred concurrent requests, add a concurrency limit (a queue or a library like p-limit) instead of firing
Promise.allon the whole array at once. - Ask whether partial failure is acceptable. If some items are allowed to fail without stopping the rest, use
Promise.allSettledinstead ofPromise.all.
When Should You Still Use Promises Directly?
Async/await isn't always the answer. Do you actually need to pause? If you're just kicking off a fire-and-forget operation, or composing Promises with Promise.race and Promise.all, the raw Promise API is cleaner. And in React, awaiting directly inside a component body doesn't work, you handle async work inside useEffect or a data library instead. Our React hooks best practices guide covers the cleanup patterns that keep async effects from leaking, and React data fetching with TanStack Query removes most of the manual async plumbing entirely.
Async/await is one of the best additions JavaScript ever shipped. It didn't add new capability, Promises already did the heavy lifting, but it made asynchronous code readable enough that ordinary developers stopped getting it wrong. Learn the error handling, learn Promise.all, avoid forEach, and you've covered 90% of what you'll ever hit.
Related
- React 19 new features - the Actions API and useActionState build async transitions directly into the component model
- Node.js 20 to 24 migration - top-level await and the stable global fetch reshape how you write async server code
- Bun vs Node vs Deno 2026 - all three runtimes support top-level await, but their async and Promise handling differ in practice
- Three pillars of JavaScript bloat - why async libraries and polyfills sneak weight into your bundle