Node.js Event Loop: The Complete Visual Guide for Backend Developers
Understanding the event loop is essential for writing performant Node.js applications. This guide explains each phase with visual diagrams, common pitfalls, and practical examples that demonstrate blocking vs non-blocking patterns.

The event loop is Node.js's secret sauce — and the #1 source of confusion for developers coming from other languages. Why does setTimeout not fire exactly on time? Why does Promise.resolve().then() run before setImmediate? Why do CPU-heavy operations freeze your entire server? This guide answers these questions with visual walkthroughs of the event loop phases.
The Event Loop in One Diagram
Node.js event loop operates in phases, each with a FIFO queue of callbacks. Here's the simplified flow:
┌───────────────────────────┐
┌─>│ timers │ // setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ // TCP errors, system operations
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ // Internal use
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ // I/O callbacks (fs, network)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ // setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ // socket.on('close')
└───────────────────────────┘
Phase 1: Timers (setTimeout, setInterval)
This phase executes callbacks scheduled by setTimeout and setInterval. Important: the delay is minimum time, not guaranteed. If the event loop is busy, your timer callback waits.
const start = Date.now();
setTimeout(() => {
console.log(`Executed after ${Date.now() - start}ms`);
}, 100);
// Simulate blocking for 200ms
while (Date.now() - start < 200) { /* block */ }
// Output: Executed after 200+ms (not 100!)
Production insight: Never trust timers for precise scheduling. For real-time applications, use setTimeout with generous buffers.
Phase 2: Pending Callbacks
Handles system-level callbacks like TCP errors. Most developers never interact directly with this phase.
Phase 3: Poll (The Heart of the Loop)
The poll phase has two jobs: retrieve new I/O events (like incoming network connections) and execute their callbacks. This is where most application code runs — fs.readFile, HTTP requests, database queries.
Key behavior: If the poll queue is empty, the event loop will either:
- Wait for pending timers to expire (sleeps until nearest timer)
- Move to check phase if
setImmediatecallbacks are pending
Phase 4: Check (setImmediate)
setImmediate callbacks execute immediately after the poll phase completes. This is for callbacks that should run after I/O but before timers.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Order is non-deterministic!
// setTimeout(0) can be 1ms or 4ms depending on system tick
Common misconception: setImmediate is not "faster" than setTimeout(0). In I/O callbacks, setImmediate always runs first:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Output: immediate, timeout (always)
Microtasks: The Hidden Queue
Between each phase, Node.js processes the microtask queue (process.nextTick and Promise.then). These run immediately and can starve the event loop if misused.
// DANGER: Infinite loop
function loop() {
process.nextTick(loop); // Never lets event loop advance
}
loop();
setTimeout(() => console.log('never runs'), 0);
Rule of thumb: Use process.nextTick sparingly — only to defer a callback but before I/O. Use setImmediate for true deferral without blocking.
Visualizing Blocking Operations
When you run synchronous CPU-heavy code, the entire event loop stops:
app.get('/compute', (req, res) => {
// BAD: Blocks ALL requests for 2 seconds
const result = heavyComputation();
res.json(result);
});
// During those 2 seconds:
// - No other requests are processed
// - Timers don’t fire
// - WebSocket connections disconnect
Fix: Offload to Worker Threads or break operation into smaller chunks using setImmediate yielding:
async function processArrayWithoutBlocking(items, callback) {
let index = 0;
function chunk() {
const start = Date.now();
while (Date.now() - start < 5 && index < items.length) {
processItem(items[index++]);
}
if (index < items.length) setImmediate(chunk);
else callback();
}
chunk();
}
Real-World Event Loop Debugging
Is your event loop lagging? Monitor process.env.NODE_DEBUG or use clinic doctor. For programmatic monitoring:
let lag = 0;
function measureEventLoopLag() {
const start = process.hrtime.bigint();
setImmediate(() => {
const end = process.hrtime.bigint();
lag = Number(end - start) / 1e6; // milliseconds
console.log(`Event loop lag: ${lag}ms`);
measureEventLoopLag();
});
}
measureEventLoopLag();
If lag exceeds 50-100ms consistently, you have blocking code or excessive microtasks.
Common Event Loop Pitfalls in Production
Pitfall #1: Synchronous JSON.parse on large payloads
Use streaming JSON parsers (JSONStream) or worker threads.
Pitfall #2: Multiple nested process.nextTick
Creates microtask avalanche. Use setImmediate instead.
Pitfall #3: Heavy crypto operations on the main thread
Always use crypto.pbkdf2 with callbacks (not sync version).
Conclusion
The event loop is not magic — it's a deterministic state machine. Once you internalize the six phases and the microtask queue, you'll write faster, more predictable Node.js code. Remember: never block the poll phase, respect microtask boundaries, and use setImmediate for deferral. Your p99 latencies will thank you.
Comments
Join the conversation — sign in to leave a comment.