Stop Writing Serial Awaits: A Reality Check
Actually, I should clarify — I rejected a Pull Request last Tuesday that made me want to scream into a pillow. It wasn’t because the code was messy or the variable names were bad. But it was because the developer — who is actually quite good — had turned a 200ms operation into a 4-second ordeal.
They fell for the classic trap. The one that makes async/await so dangerous.
It looks synchronous. It reads like a book. Top to bottom. Line by line. And that is exactly why it kills performance if you aren’t paying attention.
We’ve had async/await in JavaScript for nearly a decade now. And you’d think by 2026 we’d have ironed out the kinks, but I still see the same patterns repeating in codebases running modern Node.js 24.2.0 environments. The syntax is so clean that it hides the fact that you are literally pausing your program.
The Waterfall of Death
Here is the scenario. You need to fetch data for three different user widgets. The dashboard loads, and you need the profile, the notifications, and the subscription status.
The code I reviewed looked something like this:
async function loadDashboard(userId) {
// ❌ This is the bottleneck
const profile = await getUserProfile(userId);
const notifications = await getNotifications(userId);
const subscription = await getSubscription(userId);
return { profile, notifications, subscription };
}
But the keyword here is Then.
The Fix: Let Them Race
Unless the second request depends on data from the first (like needing a subscriptionId from the profile to fetch the subscription), there is zero reason to wait.
Fire them all off at once. Await the results together.
async function loadDashboardFast(userId) {
// ✅ Fire requests immediately
const profilePromise = getUserProfile(userId);
const notificationsPromise = getNotifications(userId);
const subscriptionPromise = getSubscription(userId);
// Wait for all of them to finish
const [profile, notifications, subscription] = await Promise.all([
profilePromise,
notificationsPromise,
subscriptionPromise
]);
return { profile, notifications, subscription };
}
The forEach Trap
You have an array of items. You want to save them to the database. You reach for forEach.
async function saveAll(items) {
// ❌ This does NOT wait!
items.forEach(async (item) => {
await db.save(item);
});
console.log('Done!');
}
The modern fix (and yes, this has been the fix for years, but people still ignore it) is for...of loops if you need sequence, or map + Promise.all if you want parallelism. As outlined in my article on Mastering Modern JavaScript Tools.
async function saveAllCorrectly(items) {
// Option A: Sequence (Slow but safe for order)
for (const item of items) {
await db.save(item);
}
// Option B: Parallel (Fast)
await Promise.all(items.map(item => db.save(item)));
}
When Promise.all isn’t enough
I’ve started enforcing usage of Promise.allSettled for these kinds of dashboard aggregations. It’s slightly more verbose to handle the results, but it’s bulletproof.
const results = await Promise.allSettled([
getUserProfile(userId),
getNotifications(userId)
]);
const profile = results[0].status === 'fulfilled' ? results[0].value : null;
const notifications = results[1].status === 'fulfilled' ? results[1].value : [];
if (!profile) {
throw new Error("Critical failure: Profile missing");
}
// If notifications failed, we just show an empty list. Graceful degradation.
A Note on Top-Level Await
But be careful using it in your main application entry points. It turns your module loading into an async operation. If you have a chain of imports that all use top-level await, you are essentially recreating the serial waterfall problem I mentioned earlier, but at the module loader level. Your app startup time can creep up silently, as discussed in my article on The ES Modules Hangover.
The Reality
Async/await is syntactic sugar. Sweet, delicious sugar. But it doesn’t change how the event loop works. It doesn’t magically make JavaScript multi-threaded (Worker Threads do that, but that’s a whole other article). It just hides the callback hell.
