I want to be clear about something before I start: async/await is good. It turned callback hell into something a human can read. I use it constantly. This is not a hot take about going back to raw Promises or, worse, callbacks.
What I want to talk about is the habit of reaching for await by default — as if it's the safe, correct choice — when in many cases it's silently serialising work that could run in parallel. In a Node.js backend handling hundreds of requests per second, those serialised milliseconds compound into real latency that's genuinely hard to diagnose.
The serialisation trap
Here's the pattern I see constantly in codebases — including my own earlier work:
async function getUserDashboard(userId: string) { const user = await db.getUser(userId); const posts = await db.getPosts(userId); const metrics = await db.getMetrics(userId); return { user, posts, metrics }; }
This reads cleanly. It's also slower than it needs to be. Each await yields the microtask queue and waits for the previous operation to resolve before even starting the next one. If each query takes 20ms, the total is ~60ms. There's no reason for this — the three queries are completely independent.
async function getUserDashboard(userId: string) { const [user, posts, metrics] = await Promise.all([ db.getUser(userId), db.getPosts(userId), db.getMetrics(userId), ]); return { user, posts, metrics }; }
Same result. Total time is now the slowest of the three queries — probably ~22ms instead of ~60ms. This is not a micro-optimisation. At 500 req/s, you're saving real CPU time and real user-facing latency.
When order actually matters
The reason people write sequential awaits is that sometimes order matters. If you need the result of the first query to construct the second, sequencing is correct:
const user = await db.getUser(userId); const posts = await db.getPostsForPlan(userId, user.plan); // depends on user.plan
This is fine. The dependency is real. The problem is when the sequential pattern gets copy-pasted into contexts where there's no actual dependency — because it's the path of least resistance and the code looks the same either way.
My rule now: before writing a sequence of await calls, I ask whether any of them actually depends on the previous result. If not, Promise.all. If some do and some don't, I split them: parallel for the independent group, sequential only where the dependency exists.
Promise.allSettled and partial failures
Promise.all has a known footgun: if any promise rejects, the whole thing rejects and the other results are discarded. For dashboard-style aggregation where each piece is independent and optional, this can be too aggressive.
Promise.allSettled is the right tool when partial failure is acceptable — when you want to show whatever succeeded rather than failing the whole request because one non-critical query threw:
const results = await Promise.allSettled([ fetchPrimaryMetrics(userId), fetchActivityFeed(userId), fetchRecommendations(userId), // non-critical ]); const [metrics, feed, recs] = results.map(r => r.status === "fulfilled" ? r.value : null );
In Supergate, we use this pattern for the analytics sidebar — we'd rather show the dashboard with missing cost data than fail the entire page because the metrics service is slow.
The event loop is not a thread pool
There's a related misconception worth clearing up. await doesn't block the event loop — that's the whole point. But the way it yields can still produce unexpected serialisation in tight loops.
If you have 50 items to process and you do:
for (const item of items) {
await processItem(item); // starts next only after this one settles
}You're processing them one at a time. The event loop is free during each await, but the throughput is serial. For independent items, this is often fine — but for I/O-bound operations like database writes or HTTP calls, batching with Promise.all or a concurrency-limited pool (I use p-limit for this) is dramatically faster.
What I actually do now
My mental model when writing async code now has three levels:
Truly independent operations: Promise.all. Always. No exceptions.
Fan-out with partial failure tolerance: Promise.allSettled, then inspect the results.
Sequential by necessity: await in sequence, but only when there's a real data dependency I can point to.
The last thing I want to say is that this is a code review concern, not a linter concern. Tools can't reliably detect independent async calls because they can't know your data dependencies. This one requires a human to notice — which means it requires the habit of asking the question every time you reach for await.