I/O waits for no one — understand the event loop or your server will crawl
I/O waits for no one — understand the event loop or your server will crawl
Lesson outline
Node.js runs JavaScript on a single thread. Somehow, it handles 50,000 concurrent connections. Python's asyncio and Go's goroutines do similar things through different mechanisms. The secret: I/O is the bottleneck, not CPU.
The I/O Wait Insight
When your server reads from a database, it waits ~5ms for the network round-trip. During those 5ms, a modern CPU can execute 5,000,000 instructions. Synchronous (blocking) code wastes all that CPU time waiting. Async code uses that time to serve other requests.
| Operation | Time | CPU cycles wasted if blocking |
|---|---|---|
| L1 cache access | 0.5ns | 1 (reference) |
| RAM access | 100ns | 200 |
| SSD read | 150μs | 300,000 |
| Database query (LAN) | 5ms | 10,000,000 |
| HTTP API call | 100ms | 200,000,000 |
| Database query (cross-region) | 150ms | 300,000,000 |
The Node.js Event Loop (simplified)
01
Your code runs synchronously until it hits an async operation (setTimeout, DB query, HTTP call)
02
Node.js registers a callback with the OS (via libuv) and immediately continues executing
03
OS handles the I/O operation (disk read, network request) in the background
04
When I/O completes, OS notifies Node.js via an event
05
Node.js puts the callback in the event queue
06
When the call stack is empty, Node.js picks the next callback from the queue and executes it
Your code runs synchronously until it hits an async operation (setTimeout, DB query, HTTP call)
Node.js registers a callback with the OS (via libuv) and immediately continues executing
OS handles the I/O operation (disk read, network request) in the background
When I/O completes, OS notifies Node.js via an event
Node.js puts the callback in the event queue
When the call stack is empty, Node.js picks the next callback from the queue and executes it
Don't Block the Event Loop
If you run synchronous CPU-intensive code (image processing, crypto, JSON.parse of a 50MB file) on the Node.js main thread, ALL requests are blocked until it finishes. Move CPU work to worker threads or a separate process.
Event Loop Phases (Node.js)
JavaScript's async story evolved over 15 years. Understanding all three helps you read older codebases and understand what async/await actually does.
Async/Await Is Syntactic Sugar
async/await doesn't add new functionality. It's syntax that makes Promise chains readable. The async keyword makes a function return a Promise. await pauses execution of that function (not the whole thread) until the Promise resolves.
1// The evolution of async patterns in JavaScript23// ❌ Callback Hell (Node.js 2009-2015)Callback hell: 5+ levels of nesting, error handling repeated at every level4function getUser(id: string, callback: (err: Error | null, user?: User) => void) {5db.query('SELECT * FROM users WHERE id = ?', [id], (err, rows) => {6if (err) return callback(err);7const user = rows[0];8db.query('SELECT * FROM posts WHERE user_id = ?', [user.id], (err, posts) => {9if (err) return callback(err);10// Imagine 3 more levels of nesting...11callback(null, { ...user, posts });12});13});14}1516// ✅ Promises (ES2015/ES6)17function getUserWithPosts(id: string): Promise<UserWithPosts> {18return db.query('SELECT * FROM users WHERE id = ?', [id])19.then(rows => rows[0])20.then(user =>21db.query('SELECT * FROM posts WHERE user_id = ?', [user.id])22.then(posts => ({ ...user, posts }))Promises flatten the nesting but .then() chains are still awkward for complex logic23);24}2526// ✅✅ Async/Await (ES2017) — same as Promises, much more readableasync/await: same Promise machinery, reads like synchronous code27async function getUserWithPostsAsync(id: string): Promise<UserWithPosts> {28const rows = await db.query('SELECT * FROM users WHERE id = ?', [id]);29const user = rows[0];30const posts = await db.query('SELECT * FROM posts WHERE user_id = ?', [user.id]);31return { ...user, posts };32}Sequential awaits: 50ms + 50ms = 100ms total. Unnecessary when requests are independent3334// ⚠️ Common mistake: sequential awaits when parallel is possible35async function slowVersion(userId: string) {36const user = await getUser(userId); // waits 50ms37const orders = await getOrders(userId); // waits another 50ms = 100ms total38return { user, orders };39}Promise.all starts both requests simultaneously. Total time = max(50ms, 50ms) = 50ms4041// ✅ Parallel with Promise.all42async function fastVersion(userId: string) {43const [user, orders] = await Promise.all([44getUser(userId), // both start simultaneously45getOrders(userId), // completes in ~50ms total46]);47return { user, orders };Always wrap top-level async code in try/catch. Unhandled rejections kill the process48}4950// ✅ Error handling with async/await51async function safeGetUser(id: string) {52try {53const user = await getUserWithPostsAsync(id);54return { success: true, data: user };55} catch (error) {56// Always catch — unhandled Promise rejections crash Node.js57console.error('Failed to get user:', error);58return { success: false, error: 'User not found' };59}60}
Advanced Async Patterns You'll Need
Promise.all vs Promise.allSettled
Promise.all fails fast — if one promise rejects, the entire call rejects immediately (other promises still run but their results are ignored). Promise.allSettled waits for all, then gives you each result with a status. Use allSettled when partial failure is acceptable.
1import pLimit from 'p-limit';23// Pattern 1: Throttle concurrent operationsp-limit queues promises beyond the limit. Prevents overwhelming databases with thousands of simultaneous queries4// Process 1000 user IDs but only 10 at a time5const limit = pLimit(10); // max 10 concurrent67async function processUsers(userIds: string[]) {8const results = await Promise.all(9userIds.map(id => limit(() => processUser(id))) // p-limit queues extras10);11return results;12}1314// Pattern 2: Retry with exponential backoff15async function withRetry<T>(16operation: () => Promise<T>,17maxRetries = 3,18baseDelayMs = 100Exponential backoff: 100ms, 200ms, 400ms. Avoids thundering herd on transient failures19): Promise<T> {20for (let attempt = 0; attempt <= maxRetries; attempt++) {21try {22return await operation();23} catch (error) {24if (attempt === maxRetries) throw error; // exhausted retries25const delay = baseDelayMs * 2 ** attempt; // 100ms, 200ms, 400ms26await new Promise(resolve => setTimeout(resolve, delay));27}28}29throw new Error('Should not reach here');Promise.race: first to settle wins. Timeout promise races against the real operation30}3132// Pattern 3: Timeout with Promise.race33async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {34const timeout = new Promise<never>((_, reject) =>35setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)36);37return Promise.race([promise, timeout]);38}3940// Usage: DB call with 5-second timeout41const user = await withTimeout(db.users.findById(id), 5000);
Async questions test fundamental JavaScript/Node.js understanding. Senior engineers know why async exists, not just the syntax.
Common questions:
Strong answers include:
Red flags:
Quick check · Async Programming: Callbacks, Promises, Async/Await
1 / 3
Key takeaways
From the books
You Don't Know JS: Async & Performance — Kyle Simpson (2015)
Chapter 1: Asynchrony: Now & Later
JavaScript's concurrency model is event-loop based, not thread-based. Understanding this model is fundamental to writing correct async code.
Ready to see how this works in the cloud?
Switch to Career Paths for structured paths (e.g. Developer, DevOps) and provider-specific lessons.
View role-based pathsSign in to track your progress and mark lessons complete.
Questions? Discuss in the community or start a thread below.
Join DiscordSign in to start or join a thread.