async/await was such a good idea that it created a new class of bug. Before it, asynchronous code looked asynchronous — callbacks, .then() chains, the whole thing shouting "careful, this runs later." Now it looks exactly like ordinary top-to-bottom code & that's the problem. When something reads like it runs in order, you stop checking whether it actually does.
These are the four I still flag most often. None of them throw an obvious error. They all look correct at a glance. That's what makes them worth knowing.
1. forEach doesn't wait for anything
This is the one I see most. You have an array, you need to do an async thing for each item & you reach for forEach because that's the loop you know.
async function chargeAll(users) {
users.forEach(async (user) => {
await chargeCard(user);
});
console.log("all charged"); // lies
}Read it out loud and it sounds right: "for each user, await the charge." But forEach doesn't understand async. It calls your callback, the callback returns a promise & forEach throws that promise on the floor and moves to the next item. All the charges start at once & console.log runs before a single one finishes. If one card fails, the rejection is unhandled — forEach isn't awaiting it, so nobody catches it.
The fix depends on what you actually want. Need them one at a time, in order? Use a real loop, because for...of respects await:
for (const user of users) {
await chargeCard(user); // genuinely sequential now
}Fine to run them all at once? Say so explicitly with Promise.all:
await Promise.all(users.map((user) => chargeCard(user)));The rule I've settled on: the moment there's an await inside a .forEach, it's wrong. Not "probably wrong" — wrong. Pick for...of or Promise.all on purpose.
An
awaitinside a.forEachis a bug, not a style choice. forEach throws the promise away — nothing waits & a rejection goes unhandled.
2. Doing in sequence what could happen at once
The opposite mistake & it's easy to make once you've been burned by the first one. You start awaiting everything carefully, in order — and now three requests that have nothing to do with each other run back to back.
const user = await fetchUser(id);
const posts = await fetchPosts(id);
const notifications = await fetchNotifications(id);If each takes 200ms, this page waits 600ms. But none of these depend on each other — fetchPosts doesn't need the result of fetchUser. There's no reason to make them stand in a line. Fire them together:
const [user, posts, notifications] = await Promise.all([
fetchUser(id),
fetchPosts(id),
fetchNotifications(id),
]);Now it's 200ms, because they overlap. The tell is simple: if you have several awaits in a row and a later one doesn't use the result of an earlier one, they shouldn't be in a row. Await sequentially only when the second thing genuinely needs the first.
3. Promise.all gives up the moment anything fails
Promise.all has a sharp edge people forget: it rejects as soon as one promise rejects & you lose every other result — including the ones that already succeeded.
// If any single row fails, you get nothing back — not even the good rows.
const results = await Promise.all(rows.map(importRow));For something like a page load where a single failure means the whole page is broken anyway, that's fine. But for a batch job — importing a hundred rows, sending a hundred emails — you usually want to know which ones worked and which didn't, not throw the whole batch away because row 43 had a bad date. That's what Promise.allSettled is for:
const results = await Promise.allSettled(rows.map(importRow));
const failed = results.filter((r) => r.status === "rejected");
console.log(`${rows.length - failed.length} imported, ${failed.length} failed`);allSettled never rejects. Every entry comes back as either { status: "fulfilled", value } or { status: "rejected", reason }, so you can act on partial success instead of pretending it's all-or-nothing.
Quick way to remember the difference
Promise.all is "all or bust" — one failure sinks everything.
Promise.allSettled is "tell me how each one went" — it always resolves
with a full report. Reach for all when a single failure means you're done
anyway; reach for allSettled when partial success is a real outcome you
need to handle.
4. The missing await that eats your errors
This one is nasty because it works. Usually. Until it doesn't & then it fails silently.
async function handler(req, res) {
try {
saveToDatabase(req.body); // no await
res.send("saved");
} catch (err) {
res.status(500).send("failed");
}
}You call saveToDatabase, but you forgot to await it. The function returns a promise immediately, the try block finishes, res.send("saved") runs — and you've told the user it saved before the save even started. If the save then fails, the rejection happens outside the try/catch, because the try block already exited. Your error handling is right there and it never fires. The user got a 200 for a write that failed.
try/catch only catches an async error if you await the thing inside the try. No await, no catch. Add the one word:
await saveToDatabase(req.body);Half of what makes async bugs hard is that a forgotten await doesn't error — it just quietly does the wrong thing at the wrong time. When something asynchronous behaves strangely, "is there an await missing?" is the first question worth asking.
What ties these together
Every one of these reads correctly. That's the whole lesson. Because async/await makes asynchronous code look synchronous, your eyes stop treating it as asynchronous — and these bugs live exactly in the gap between how the code looks and how it runs.
A few habits that catch most of them before review does:
- Never put an
awaitinside.forEach. Usefor...offor sequential,Promise.allfor parallel — and choose on purpose. - Awaiting things in a row? Check whether they actually depend on each other. If not,
Promise.allthem. - Ask whether partial failure is a real case. If it is,
allSettled, notall. - Treat every un-awaited async call as suspicious. Most of the time the missing
awaitis the bug.
None of this is exotic. It's the same handful of mistakes, made by good developers, precisely because the modern syntax is comfortable enough to stop paying attention. Keep paying attention where the code goes quiet.