Asynchronous JavaScript: Promises, async/await, and Common Pitfalls
A few months ago, I was reviewing a pull request from a junior dev on my team. The code looked clean—async/await everywhere, proper error handling, the works. But when I ran it, the dashboard took 8 seconds to load. The same dashboard that used to load in 2 seconds.
The problem? Three API calls that could run in parallel were running one after another. The code looked right, but it was doing something completely different under the hood.
That's the thing about async/await—it's deceptively simple. It makes asynchronous code look synchronous, which is great for readability but terrible if you forget what's actually happening. I've seen this pattern cause production incidents, and I've made the same mistakes myself earlier in my career.
Let me walk you through the traps I see most often.
Quick Refresher: Promises → async/await
If you already know this, skip ahead. But just to make sure we're on the same page: async/await is syntactic sugar over Promises. Nothing more.
// Promise chains (the old way)
function getUser(id) {
return fetch(`/api/users/${id}`)
.then(response => response.json())
.then(user => {
console.log(user);
return user;
})
.catch(error => console.error("Failed", error));
}
// async/await (same thing, cleaner syntax)
async function getUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
console.log(user);
return user;
} catch (error) {
console.error("Failed", error);
}
}
Both do exactly the same thing. The second one just reads more like normal code. Now let's look at where things go wrong.
The forEach Trap
This one bites everyone at least once. I guarantee you've either written this bug or will write it someday.
Here's the scenario: you have an array of items, and you need to save each one to a database. Seems simple enough.
// ❌ This doesn't work the way you think
async function processAll(items) {
console.log("Starting...");
items.forEach(async (item) => {
await saveItemToDatabase(item);
console.log("Saved item");
});
console.log("Done!");
}
Run this code. "Done!" will log before any items are saved. Why? Because forEach doesn't care about the promises your callback returns. It just fires off all the callbacks immediately and moves on.
The fix is boring but correct—use a regular for loop:
// ✅ This actually waits
async function processAll(items) {
console.log("Starting...");
for (const item of items) {
await saveItemToDatabase(item);
console.log("Saved item");
}
console.log("Done!");
}
I've started just avoiding forEach entirely in async code. It's not worth the mental overhead of remembering this quirk.
The Waterfall Problem
This is what happened in that PR I mentioned at the start. Here's a simplified version:
// ❌ Each request waits for the previous one
async function loadDashboard(userId) {
const user = await api.getUser(userId); // 200ms
const orders = await api.getOrders(userId); // 200ms
const notifications = await api.getNotifications(userId); // 200ms
// Total: 600ms
return { user, orders, notifications };
}
These three API calls have nothing to do with each other. You don't need the user data to fetch orders. But because of where we put the await keywords, they run sequentially—a "waterfall."
The fix is to start all requests immediately, then wait for them all at once:
// ✅ All requests run at the same time
async function loadDashboard(userId) {
const userPromise = api.getUser(userId);
const ordersPromise = api.getOrders(userId);
const notificationsPromise = api.getNotifications(userId);
const [user, orders, notifications] = await Promise.all([
userPromise,
ordersPromise,
notificationsPromise
]);
// Total: ~200ms (whatever the slowest one takes)
return { user, orders, notifications };
}
We went from 600ms to 200ms. On a dashboard with 5-6 API calls, this difference is huge.
When Promise.all Fails You
Promise.all has a gotcha though. It's "fail-fast"—if any single promise rejects, the entire thing throws immediately. You lose the results from the promises that succeeded.
Sometimes that's what you want. But for a dashboard where some widgets are optional? Not great. If the notifications API is down, you probably still want to show the user's orders.
Enter Promise.allSettled:
const results = await Promise.allSettled([
api.getUser(userId),
api.getOrders(userId),
api.getNotifications(userId) // This one fails
]);
// results looks like:
// [
// { status: 'fulfilled', value: {...} },
// { status: 'fulfilled', value: [...] },
// { status: 'rejected', reason: Error(...) }
// ]
// You can handle each result individually
const user = results[0].status === 'fulfilled' ? results[0].value : null;
const orders = results[1].status === 'fulfilled' ? results[1].value : [];
// Show error message for notifications widget only
I use this pattern a lot for pages where graceful degradation matters more than all-or-nothing behavior.
The Forgotten await
This one sounds dumb, but I've seen it cause real bugs in production. You call an async function but forget the await:
async function handler() {
try {
saveData({ id: 1 }); // Oops, forgot await
console.log("Saved!");
} catch (e) {
console.error(e);
}
}
"Saved!" logs immediately. If saveData fails later, that catch block won't catch it—the error happens after the function has already finished executing.
This is why I always configure ESLint with @typescript-eslint/no-floating-promises or the equivalent. Let the tooling catch these before they become 3am pages.
Wrapping Up
async/await is one of the best things to happen to JavaScript. It makes async code readable. But it's not magic—you still need to understand what's happening underneath.
The short version:
- Don't use
forEachwith async callbacks. Usefor...ofinstead. - If your requests don't depend on each other, run them in parallel with
Promise.all. - Use
Promise.allSettledwhen you need partial success. - Set up a linter to catch missing
awaits. Your future self will thank you.