Quick take: Async/await is syntax over Promises. Every async function returns a Promise, and await pauses until that Promise settles. Handle failures with try/catch, run independent calls together with Promise.all, and never await inside forEach.
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.
I've debugged more broken async code than I can count. 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.
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. I've seen this cost a team half a day, they assumed a failed HTTP status would land in the catch block, and it never did. When you type these responses with TypeScript generics, the compiler nudges you toward handling the error shape, but the runtime check still has to be explicit.
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. On a real dashboard I refactored, moving six independent API calls from sequential awaits to a single Promise.all dropped initial load from 1.4 seconds to 380 milliseconds. Same requests, same server, just stopped 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.
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.
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