Node.js Event Loop: A Visual Guide to Async Programming (2026)
The event loop is what makes Node.js fast. Understanding it means writing faster, bug-free async code.
The Big Picture
┌───────────────────────────────────┐
│ Your Code │ ← JavaScript (single thread!)
│ (synchronous) │
└─────────────┬─────────────────────┘
│ async operations
▼
┌───────────────────────────────────┐
│ Node.js APIs │ ← C++ (multi-threaded!)
│ fs, crypto, dns, http, zlib... │
└─────────────┬─────────────────────┘
│ callback/task queued
▼
┌───────────────────────────────────┐
│ Event Loop │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │timers│ │pending│ │idle │ │poll │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ Runs callbacks from each phase │
│ in order, one at a time │
└───────────────────────────────────┘
Key insight: Node.js is single-threaded for JS code,
but the underlying C++ APIs use thread pools for I/O!
The 6 Phases of the Event Loop
// Each iteration of the event loop runs through these phases:
// Phase 1: Timers (setTimeout, setInterval)
// → Callbacks whose timer has expired
setTimeout(() => console.log('timer1'), 0);
setTimeout(() => console.log('timer2'), 10);
// Phase 2: Pending Callbacks
// → I/O callbacks deferred from previous loop iteration
// (mostly internal Node.js use — you rarely interact directly)
// Phase 3: Idle/Prepare
// → Internal use only (you can ignore this)
// Phase 4: Poll (THE MOST IMPORTANT PHASE!)
// → Retrieves new I/O events
// → Executes I/O callbacks (fs.read, network responses, etc.)
// → Blocks here if there's nothing else to do
// Phase 5: Check (setImmediate)
// → setImmediate() callbacks run HERE (after I/O)
setImmediate(() => console.log('immediate'));
// Phase 6: Close Callbacks
// → socket.on('close'), stream.on('close') etc.
Execution Order: What Runs First?
// THE CLASSIC INTERVIEW QUESTION:
// In what order do these log?
console.log('start'); // 1. Synchronous code first
setTimeout(() => console.log('timeout'), 0); // 4. Timer phase (next tick)
setImmediate(() => console.log('immediate')); // 5. Check phase (after poll)
Promise.resolve().then(() => {
console.log('promise'); // 2. Microtask queue (after sync)
});
process.nextTick(() => {
console.log('nextTick'); // 3. Next tick queue (before promise!)
});
fs.readFile(__filename, () => {
console.log('file read'); // 6. Poll phase (I/O callback)
});
// Output order:
// start → nextTick → promise → timeout/immediate (order varies!) → file read
//
// Why timeout/immediate order varies?
// It depends on which event loop cycle they land in.
// If both are ready on the SAME cycle: timers run before check (usually).
// But if the loop is already past timers when immediate is queued...
// DON'T rely on the order between setTimeout(0) and setImmediate!
// RELIABLE ORDERING:
// nextTick > Promise > (setTimeout ≈ setImmediate, don't depend on it) > I/O
Microtasks vs Macrotasks
// Microtask queues (drain AFTER each macrotask):
// - process.nextTick()
// - Promise.then/catch/finally
// - queueMicrotask()
// Macrotask (each triggers a full event loop iteration):
// - setTimeout / setInterval
// - setImmediate
// - I/O callbacks
// - UI rendering (browser)
// CRITICAL: Microtasks can STARVE the event loop!
function badInfiniteLoop() {
// This NEVER yields control back to the event loop
return Promise.resolve().then(() => badInfiniteLoop());
}
// Result: No timers fire, no I/O handled, server freezes!
// Fix: Use setImmediate or setTimeout to yield:
function goodYield() {
return new Promise(resolve => {
setImmediate(() => { // Yields to event loop between iterations
goodYield();
resolve();
});
});
}
// Practical rule:
// nextTick/Promise = "do this ASAP but after current sync code"
// setImmediate/setTimeout = "do this on next event loop cycle"
Blocking the Event Loop
// ❌ BLOCKING: CPU-intensive operation freezes everything
app.get('/compute', (req, res) => {
const result = heavyComputation(data); // Takes 5 seconds
res.json(result);
});
// During those 5 seconds: NO other requests are handled!
// Server appears frozen.
// ✅ Solution 1: Offload to worker threads
const { Worker } = require('worker_threads');
app.get('/compute', (req, res) => {
const worker = new Worker('./compute-worker.js', { workerData: data });
worker.on('message', result => res.json(result));
worker.on('error', err => res.status(500).send(err.message));
});
// compute-worker.js:
const { parentPort, workerData } = require('worker_threads');
const result = heavyComputation(workerData);
parentPort.postMessage(result);
// ✅ Solution 2: Break into chunks with setImmediate
function processLargeArray(array, chunkSize, processor) {
let index = 0;
function processChunk() {
const chunk = array.slice(index, index + chunkSize);
chunk.forEach(processor);
index += chunkSize;
if (index < array.length) {
setImmediate(processChunk); // Yield between chunks
} else {
console.log('Done!');
}
}
processChunk();
}
// ✅ Solution 3: Use child_process.fork (separate Node process)
const { fork } = require('child_process');
const child = fork('heavy-task.js');
child.send(data);
child.on('message', result => res.json(result));
Async/Await Under the Hood
// What actually happens when you use async/await?
async function getUser(id) {
const user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
const posts = await db.query('SELECT * FROM posts WHERE user_id = ?', [id]);
return { user, posts };
}
// Roughly equivalent to:
function getUser(id) {
return db.query('SELECT * FROM users WHERE id = ?', [id])
.then(user => {
return db.query('SELECT * FROM posts WHERE user_id = ?', [id])
.then(posts => ({ user, posts }));
});
}
// Key points:
// 1. await splits execution into separate microtask callbacks
// 2. Each await yields to the event loop (other code CAN run between awaits)
// 3. Error handling: try/catch becomes .catch()
// ⚠️ Common mistake: Sequential awaits when parallel would be faster
async function slow() {
const user = await fetchUser(id); // Wait 100ms
const posts = await fetchPosts(id); // Wait another 100ms
const comments = await fetchComments(id); // Wait another 100ms
// Total: ~300ms
}
async function fast() {
const [user, posts, comments] = await Promise.all([
fetchUser(id), // All start simultaneously
fetchPosts(id),
fetchComments(id),
]);
// Total: ~100ms (longest single request)
}
// Rule of thumb: If awaits don't depend on each other → Promise.all them!
Streams: Backpressure & Memory
// Problem: Reading a 10GB file into memory crashes your server
const data = fs.readFileSync('huge-file.json'); // ❌ 10GB in RAM!
// Solution: Streams (process data in small chunks)
const stream = fs.createReadStream('huge-file.json', { highWaterMark: 64 * 1024 }); // 64KB chunks
let chunkCount = 0;
stream.on('data', (chunk) => {
chunkCount++;
processChunk(chunk); // Process 64KB at a time
// Memory stays constant regardless of file size!
});
stream.on('end', () => {
console.log(`Done! ${chunkCount} chunks processed`);
});
stream.on('error', (err) => {
console.error('Stream error:', err);
});
// Pipe pattern (connect streams together):
const readStream = fs.createReadStream('input.txt');
const gzip = createGzip();
const writeStream = fs.createWriteStream('output.txt.gz');
readStream.pipe(gzip).pipe(writeStream);
// Automatic backpressure: if writeStream is slow,
// readStream automatically slows down!
// Stream helpers (Node.js 18+):
import { pipeline } from 'stream/promises';
await pipeline(
fs.createReadStream('input.csv'),
new Transform({
transform(chunk, encoding, callback) {
// Transform each chunk
callback(null, chunk.toString().toUpperCase());
}
}),
fs.createWriteStream('output-uppercase.csv')
);
// Handles errors, cleanup, and backpressure automatically!
Practical Debugging
// Is your event loop blocked? Measure it:
const start = process.hrtime.bigint();
setInterval(() => {
const now = process.hrtime.bigint();
const ms = Number(now - start) / 1_000_000; // Convert nanoseconds to ms
if (ms > 100) {
console.warn(`Event loop delay: ${ms.toFixed(0)}ms (>100ms = blocked!)`);
}
// Reset for next measurement
Object.assign(start, process.hrtime.bigint());
}, 1000); // Check every second
// Or use a library:
// - blocked-at (npm): Detects blocking automatically
// - clinic.js (npm): Full performance profiling
// Memory leak detection:
setInterval(() => {
const used = process.memoryUsage();
console.log(
`RSS: ${(used.heapUsed / 1024 / 1024).toFixed(0)}MB | ` +
`Heap: ${(used.heapTotal / 1024 / 1024).toFixed(0)}MB`
);
}, 5000);
// If RSS keeps growing → memory leak somewhere!
What part of the event loop confuses you most? Share your questions!
Follow @armorbreak for more practical developer guides.
Top comments (0)