How Naive Async/Await Chains Stall the Node.js Event Loop
Naive async/await use can silenty decimate Node.js performance by creating hidden waterfall chains. Each await serializes operations, blocking concurrency and forcing other requests to wait in line. This article explores why this stall happens at the microtask level and provides concrete code examples and benchmarks showing how to correctly use Promise.all to parallelize independent I/O. Unlock your system's full potential for higher throughput and lower latency.

Node.js Performance Pitfall: Avoid Chained Await Patterns
Node.js’s event loop is designed to handle many tasks by breaking work into small pieces.
There are:
- Macro-tasks → requests, timers, I/O
- Microtasks → promise resolutions
Heavy operations (DB queries, file reads, crypto) run in the libuv thread pool (default: 4 threads).
However, naive await usage can silently destroy concurrency.
The Problem
await query1();
await query2();
await query3();
This creates a hidden waterfall:
- Each task waits for the previous one
- Only 1 thread is used at a time
- Event loop is blocked by microtasks
Deep Dive: Why This Happens
When await is used:
- Function pauses
- Promise resolves → added to microtask queue
- Node executes microtasks immediately before next I/O
Result:
- Event loop keeps executing microtasks
- Other requests must wait
Timeline Example
| Time | Event |
|---|---|
| 0ms | Request received |
| 0ms | Query #1 starts |
| 10ms | Query #1 completes |
| 10ms | Microtask → Query #2 starts |
| 20ms | Query #2 completes |
| 20ms | Microtask → Query #3 starts |
| 30ms | Query #3 completes |
| 30ms | Response sent |
Benchmark Results
Sequential Awaits
- ~150 req/sec
- ~250 ms median latency
- ~320 ms p95
Parallel (Promise.all)
- ~450 req/sec
- ~80 ms median latency
- ~120 ms p95
Fix: Parallelize Your Code
Bad Pattern
async function fetchAllData(userId) {
const user = await getUser(userId);
const posts = await getPosts(user.id);
const events = await getEvents(user.id);
return { user, posts, events };
}
Good Pattern
const timeout = ms => new Promise((_, rej) =>
setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)
);
async function fetchAllData(userId) {
const results = await Promise.allSettled([
Promise.race([getUser(userId), timeout(200)]),
Promise.race([getPosts(userId), timeout(200)]),
Promise.race([getEvents(userId), timeout(200)])
]);
const [userRes, postsRes, eventsRes] = results;
if (userRes.status === 'rejected') {
throw userRes.reason;
}
return {
user: userRes.value,
posts: postsRes.status === 'fulfilled' ? postsRes.value : [],
events: eventsRes.status === 'fulfilled' ? eventsRes.value : []
};
}
Best Practices
- Parallelize independent I/O
- Keep event loop tasks short
- Use timeouts
- Tune thread pool
- Offload heavy work
Conclusion
Naive async/await chains can:
- Kill performance
- Block the event loop
- Waste system resources
Use concurrency to unlock:
- Higher throughput
- Lower latency
- Better scalability
Enjoyed this article?
Check out more of my work or get in touch to discuss your next project.