TL;DR: The event loop is how Node.js handles thousands of concurrent operations with a single thread. It's not magic — it's a loop with 6 phases, each processing a specific type of callback. This post breaks down every phase with diagrams, shows you exactly what happens when you run async code, and explains the tricky timing bugs that bite even senior developers.
The Problem
Ask 10 Node.js developers how the event loop works, and you'll get 12 different answers. Most explanations are either:
- Too simplified: "It's just a loop that checks for callbacks" (unhelpful)
- Too academic: 50 pages of libuv source code (nobody reads this)
- Flat-out wrong: "setTimeout(fn, 0) runs immediately" (it doesn't)
I've seen bugs in production — at Mstock, with 100K+ concurrent users — that were caused by developers misunderstanding the event loop. A misplaced process.nextTick() caused a recursive callback that starved I/O and froze the server during peak trading hours.
Let me explain it the way I wish someone had explained it to me.
The Big Picture: What Is the Event Loop?
Node.js is single-threaded for your JavaScript code. But it handles I/O (file reads, network requests, database queries) by offloading those operations to the OS kernel or a thread pool, and then processing the results via callbacks.
The event loop is the mechanism that coordinates this. It's literally a loop — an infinite while(true) that keeps running as long as there's work to do.
THE EVENT LOOP — SIMPLIFIED VIEW
═════════════════════════════════
┌──────────────────────────────────────────────┐
│ │
│ Your JavaScript Code (Single Thread) │
│ │
│ console.log('start'); │
│ fs.readFile('data.json', callback); │
│ setTimeout(fn, 100); │
│ http.get(url, callback); │
│ console.log('end'); │
│ │
└──────────────────┬───────────────────────────┘
│
│ "Hey OS/thread pool,
│ handle these I/O tasks"
│
▼
┌──────────────────────────────────────────────┐
│ │
│ OS Kernel / libuv Thread Pool │
│ (Does the actual I/O work) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Thread 1│ │ Thread 2 │ │ Thread 3 │ │
│ │ fs.read │ │ dns.look │ │ crypto │ │
│ └─────────┘ └──────────┘ └──────────┘ │
│ │
└──────────────────┬───────────────────────────┘
│
│ "Done! Here are the results"
│
▼
┌──────────────────────────────────────────────┐
│ │
│ EVENT LOOP │
│ (Picks up completed I/O callbacks │
│ and runs them on the main thread) │
│ │
│ while (hasWork()) { │
│ processTimers(); │
│ processIOCallbacks(); │
│ processImmediates(); │
│ // ... more phases │
│ } │
│ │
└──────────────────────────────────────────────┘
The 6 Phases of the Event Loop
This is where most explanations go wrong. The event loop isn't one queue — it's 6 phases, each with its own queue. The loop visits each phase in order, processes all callbacks in that phase's queue, and then moves to the next phase.
THE 6 PHASES (executed in order, every iteration)
═════════════════════════════════════════════════
┌───────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────┐ │
│ │ 1. TIMERS │ │
│ │ setTimeout(), setInterval() │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ 2. PENDING CALLBACKS │ │
│ │ System-level callbacks │ │
│ │ (TCP errors, etc.) │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ 3. IDLE / PREPARE │ │
│ │ Internal use only │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ 4. POLL │ │
│ │ I/O callbacks: fs, net, etc. │◀──── Most time spent here
│ │ (This is where Node "waits") │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ 5. CHECK │ │
│ │ setImmediate() callbacks │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ ┌──────────────▼──────────────────┐ │
│ │ 6. CLOSE CALLBACKS │ │
│ │ socket.on('close'), etc. │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ └───────────────────────┘
│ Loop back to Phase 1
└───────────────────────────────────────────┘
flowchart TD
A[Timers<br/>setTimeout, setInterval] --> B[Pending Callbacks<br/>System-level callbacks]
B --> C[Idle / Prepare<br/>Internal use only]
C --> D[Poll<br/>I/O callbacks - fs, net, http<br/>Node waits here if nothing to do]
D --> E[Check<br/>setImmediate callbacks]
E --> F[Close Callbacks<br/>socket.on close]
F --> A
style D fill:#3b82f6,color:#fff
style A fill:#f59e0b,color:#fff
style E fill:#22c55e,color:#fff
Phase 1: Timers
Executes callbacks scheduled by setTimeout() and setInterval().
Important: The timer specifies a minimum delay, not an exact delay. If the event loop is busy processing I/O callbacks, your timer callback runs later than expected.
// timer-demo.js — Timers don't run at exact times
const start = Date.now();
setTimeout(() => {
console.log(`Timer fired after ${Date.now() - start}ms`);
// Expected: ~100ms
// Actual: 100-115ms (depending on event loop load)
}, 100);
// Simulate work that delays the timer
const blockFor50ms = Date.now() + 50;
while (Date.now() < blockFor50ms) {} // Blocking the loop
// Output: "Timer fired after ~150ms" (100ms delay + 50ms blocking)
Phase 2: Pending Callbacks
Executes I/O callbacks that were deferred from the previous loop iteration. This handles system-level errors like TCP ECONNREFUSED.
You rarely interact with this phase directly.
Phase 3: Idle / Prepare
Internal to Node.js and libuv. Not relevant to application code.
Phase 4: Poll (The Most Important Phase)
This is where Node.js spends most of its time. The poll phase does two things:
- Calculates how long to block and wait for I/O events
- Processes I/O callbacks in the poll queue (file reads, network responses, etc.)
POLL PHASE BEHAVIOR
════════════════════
Enter Poll Phase
│
▼
┌─────────────────────┐
│ Poll queue empty? │
└──────────┬──────────┘
│
┌───────┼───────┐
│ NO │ │ YES
▼ │ ▼
┌──────────┐ ┌──────────────────────┐
│ Execute │ │ Any setImmediate() │
│ ALL I/O │ │ callbacks scheduled? │
│ callbacks│ └──────────┬───────────┘
│ in queue │ ┌─────┼─────┐
└──────────┘ │ YES │ │ NO
▼ │ ▼
┌──────────┐ │ ┌──────────────┐
│ Move to │ │ │ WAIT here │
│ Check │ │ │ for new I/O │
│ phase │ │ │ events │
└──────────┘ │ │ (up to timer │
│ │ threshold) │
│ └──────────────┘
Key insight: When the poll queue is empty and no setImmediate() is scheduled, Node.js blocks here, waiting for new I/O events. This is how Node.js achieves near-zero CPU usage when idle.
Phase 5: Check
Executes setImmediate() callbacks. This phase runs immediately after the poll phase completes.
// setImmediate runs after I/O, setTimeout runs at start of next loop
const fs = require('fs');
fs.readFile(__filename, () => {
// Inside an I/O callback...
setImmediate(() => {
console.log('1. setImmediate'); // Runs FIRST (check phase is next)
});
setTimeout(() => {
console.log('2. setTimeout'); // Runs SECOND (timers phase, next iteration)
}, 0);
});
// Output (guaranteed order):
// 1. setImmediate
// 2. setTimeout
Phase 6: Close Callbacks
Handles close events like socket.on('close'). Cleanup phase.
Microtasks: The Queue That Jumps the Line
Here's what trips up most developers: between every phase (and even between callbacks within a phase), Node.js processes the microtask queues. These are:
-
process.nextTick()queue (highest priority) -
Promiseresolution queue
MICROTASKS — THEY RUN BETWEEN EVERY PHASE
══════════════════════════════════════════
┌────────┐ ┌─────────┐ ┌────────┐ ┌─────────┐ ┌────────┐
│ Timers │──▶│ Micro- │──▶│Pending │──▶│ Micro- │──▶│ Poll │─ ─ ▶
│ │ │ tasks │ │Callback│ │ tasks │ │ │
└────────┘ └─────────┘ └────────┘ └─────────┘ └────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│nextTick()│ │nextTick()│
│Promise │ │Promise │
└──────────┘ └──────────┘
Microtask queues are drained COMPLETELY between phases.
If you keep adding to them, they STARVE the event loop.
flowchart LR
T[Timers] --> M1[Microtasks<br/>nextTick + Promises]
M1 --> P[Pending]
P --> M2[Microtasks]
M2 --> I[Idle]
I --> M3[Microtasks]
M3 --> PO[Poll]
PO --> M4[Microtasks]
M4 --> C[Check]
C --> M5[Microtasks]
M5 --> CL[Close]
CL --> M6[Microtasks]
M6 --> T
style M1 fill:#ef4444,color:#fff
style M2 fill:#ef4444,color:#fff
style M3 fill:#ef4444,color:#fff
style M4 fill:#ef4444,color:#fff
style M5 fill:#ef4444,color:#fff
style M6 fill:#ef4444,color:#fff
process.nextTick() vs Promise vs setTimeout vs setImmediate
This is the single most confusing part of Node.js. Here's the definitive ordering:
// ordering-demo.js — Run this to see the exact execution order
console.log('1. Script start');
setTimeout(() => console.log('2. setTimeout'), 0);
setImmediate(() => console.log('3. setImmediate'));
Promise.resolve().then(() => console.log('4. Promise'));
process.nextTick(() => console.log('5. nextTick'));
console.log('6. Script end');
// OUTPUT:
// 1. Script start ← Synchronous (runs immediately)
// 6. Script end ← Synchronous (runs immediately)
// 5. nextTick ← Microtask (highest priority, before promises)
// 4. Promise ← Microtask (after nextTick, before macrotasks)
// 2. setTimeout ← Macrotask (timers phase)*
// 3. setImmediate ← Macrotask (check phase)*
//
// * setTimeout vs setImmediate order is NON-DETERMINISTIC
// when called from the main module (outside I/O callbacks)
EXECUTION PRIORITY (highest → lowest)
══════════════════════════════════════
┌─────────────────────────────────────┐
│ 1. Synchronous code │ ← Runs first, always
├─────────────────────────────────────┤
│ 2. process.nextTick() │ ← Microtask, highest async priority
├─────────────────────────────────────┤
│ 3. Promise.then() / await │ ← Microtask, after nextTick
├─────────────────────────────────────┤
│ 4. setTimeout(fn, 0) │ ← Macrotask, timers phase
├─────────────────────────────────────┤
│ 5. setImmediate(fn) │ ← Macrotask, check phase
├─────────────────────────────────────┤
│ 6. I/O callbacks │ ← Poll phase
└─────────────────────────────────────┘
The Dangerous Recursive nextTick
This is the bug we hit at Mstock. process.nextTick() callbacks are processed before any I/O — if you call nextTick recursively, you starve the event loop:
// DANGEROUS — this starves all I/O
function badRecursion() {
process.nextTick(() => {
doSomeWork();
badRecursion(); // nextTick queue never empties!
});
}
badRecursion();
// Result: setTimeout, setImmediate, I/O callbacks NEVER execute
// The event loop is stuck processing nextTick forever
// SAFE — use setImmediate for recursive operations
function safeRecursion() {
setImmediate(() => {
doSomeWork();
safeRecursion(); // Gives I/O a chance to run between iterations
});
}
RECURSIVE nextTick vs setImmediate
═══════════════════════════════════
nextTick (DANGEROUS):
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ tick │→│ tick │→│ tick │→│ tick │→│ tick │→ ... forever
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘
I/O: ❌ NEVER RUNS ❌
setImmediate (SAFE):
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ imm │→│ I/O │→│ imm │→│ I/O │→│ imm │→ ...
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘
I/O: ✅ RUNS BETWEEN EACH ✅
Real-World Example: Event Loop in a Stock Trading Server
Here's how the event loop handles a typical moment during market hours at Mstock:
ONE TICK OF THE EVENT LOOP (during market hours)
═══════════════════════════════════════════════════
Phase 1: TIMERS
├── Price refresh timer (every 1s) → fetch latest prices from exchange
├── Session cleanup timer (every 30s) → remove expired sessions
└── Health check timer (every 5s) → ping database and Redis
Phase 2: PENDING CALLBACKS
└── TCP error from disconnected client → log and clean up
Phase 4: POLL (I/O)
├── WebSocket message: user subscribes to RELIANCE → add to channel
├── WebSocket message: user places BUY order → validate + submit
├── Redis response: price update for INFY → broadcast to subscribers
├── PostgreSQL response: order saved → send confirmation to user
└── HTTP request: GET /api/portfolio → return cached data
Phase 5: CHECK
└── setImmediate: batch-write 50 price updates to TimescaleDB
MICROTASKS (between each phase):
├── nextTick: emit 'order-confirmed' event
└── Promise: resolve async order validation pipeline
// real-world-loop.js — How these phases interact in a trading server
const http = require('http');
const Redis = require('ioredis');
const { Pool } = require('pg');
const redis = new Redis();
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const priceBatch = [];
// TIMERS PHASE — scheduled recurring tasks
setInterval(async () => {
// Fetch latest prices from exchange feed
const prices = await exchangeAPI.getLatestPrices();
// Publish to Redis (triggers I/O callbacks for subscribers)
for (const price of prices) {
await redis.publish(`price:${price.symbol}`, JSON.stringify(price));
priceBatch.push(price);
}
}, 1000);
// CHECK PHASE — batch writes using setImmediate
setInterval(() => {
if (priceBatch.length > 0) {
const batch = priceBatch.splice(0, priceBatch.length);
// setImmediate ensures I/O from poll phase is processed first
setImmediate(async () => {
await batchInsertPrices(db, batch);
});
}
}, 5000);
// POLL PHASE — HTTP server handles incoming requests
const server = http.createServer(async (req, res) => {
if (req.url === '/api/portfolio') {
// This callback runs in the poll phase
const portfolio = await getPortfolio(req.userId);
// nextTick ensures response is sent before any other I/O
process.nextTick(() => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(portfolio));
});
}
});
Common Mistakes (and How to Fix Them)
1. Blocking the Event Loop
The cardinal sin. If you block the main thread, nothing else runs — no timers, no I/O, no WebSocket messages.
// BAD — blocks the event loop for ~2 seconds
app.get('/api/report', (req, res) => {
const data = fs.readFileSync('huge-file.csv'); // SYNCHRONOUS!
const parsed = parseCSV(data); // CPU-intensive!
res.json(parsed);
});
// GOOD — non-blocking with streaming
app.get('/api/report', (req, res) => {
const stream = fs.createReadStream('huge-file.csv');
stream.pipe(csvParser()).pipe(res);
});
// GOOD — offload CPU work to worker thread
const { Worker } = require('worker_threads');
app.get('/api/report', (req, res) => {
const worker = new Worker('./csv-worker.js', {
workerData: { file: 'huge-file.csv' },
});
worker.on('message', (result) => res.json(result));
worker.on('error', (err) => res.status(500).json({ error: err.message }));
});
2. Assuming setTimeout(fn, 0) Is Instant
// This does NOT run "immediately"
setTimeout(() => {
console.log('This runs after ALL microtasks + at least 1ms delay');
}, 0);
// If you need "as soon as possible after I/O":
setImmediate(() => {
console.log('This runs right after the current poll phase');
});
// If you need "before anything else async":
process.nextTick(() => {
console.log('This runs before setTimeout, setImmediate, and I/O');
});
3. Unhandled Promise Rejections
Unhandled rejections used to be silently swallowed. Since Node 15, they crash the process:
// BAD — unhandled rejection
async function fetchUser(id) {
const res = await db.query('SELECT * FROM users WHERE id = $1', [id]);
return res.rows[0]; // What if db.query throws?
}
fetchUser(123); // No .catch() → process crashes
// GOOD — always handle rejections
fetchUser(123).catch(err => console.error('Failed:', err));
// Or globally (as safety net, not primary handling):
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Log and exit gracefully
});
4. Not Understanding async/await and the Event Loop
await doesn't block the event loop — it yields control back to it:
// What actually happens with async/await:
async function handleRequest() {
console.log('A'); // Synchronous
const data = await fetchFromDB(); // Yields to event loop here
console.log('B'); // Runs when promise resolves
return data;
}
// Is equivalent to:
function handleRequest() {
console.log('A');
return fetchFromDB().then(data => {
console.log('B');
return data;
});
}
// The event loop is FREE to handle other requests while awaiting
async/await UNDER THE HOOD
══════════════════════════
handleRequest()
│
▼
console.log('A') ← Synchronous, runs now
│
▼
await fetchFromDB() ← Sends query, YIELDS to event loop
│ Event loop handles other work:
│ ├── WebSocket messages
│ ├── Other HTTP requests
│ ├── Timer callbacks
│ └── Price updates
│
▼ (when DB responds)
console.log('B') ← Runs as microtask (promise resolution)
│
▼
return data
How to Monitor Your Event Loop
If your event loop is being blocked or starved, your server slows down for everyone. Here's how to detect it:
// event-loop-monitor.js — Detect event loop delays
let lastCheck = Date.now();
setInterval(() => {
const now = Date.now();
const delay = now - lastCheck - 1000; // Expected: 0ms
lastCheck = now;
if (delay > 50) {
console.warn(`Event loop delayed by ${delay}ms — potential blocking operation!`);
}
}, 1000);
// For production: use the built-in perf_hooks
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log({
min: histogram.min / 1e6, // Convert ns to ms
max: histogram.max / 1e6,
mean: histogram.mean / 1e6,
p99: histogram.percentile(99) / 1e6,
});
histogram.reset();
}, 5000);
When to Use What
┌──────────────────────┬──────────────────────────────────────┐
│ API │ When to use │
├──────────────────────┼──────────────────────────────────────┤
│ process.nextTick() │ Emit events after constructor │
│ │ returns. Ensure callback runs │
│ │ before ANY I/O. │
├──────────────────────┼──────────────────────────────────────┤
│ Promise / await │ Async operations. Standard flow │
│ │ control. 99% of the time, use this. │
├──────────────────────┼──────────────────────────────────────┤
│ setTimeout(fn, 0) │ Defer work to next loop iteration. │
│ │ Give I/O a chance to breathe. │
├──────────────────────┼──────────────────────────────────────┤
│ setImmediate() │ Run callback after I/O events in │
│ │ current loop. Best for recursive │
│ │ operations. │
├──────────────────────┼──────────────────────────────────────┤
│ Worker Threads │ CPU-intensive work (parsing, crypto, │
│ │ compression). Keep the event loop │
│ │ free. │
└──────────────────────┴──────────────────────────────────────┘
Key Takeaways
- The event loop has 6 phases: Timers → Pending → Idle → Poll → Check → Close
- Microtasks (nextTick, Promises) run between every phase — they have the highest priority
- Poll phase is where Node spends most time — it blocks here waiting for I/O when idle
-
process.nextTick()runs before Promises, which run beforesetTimeout(0), which runs beforesetImmediate() -
Inside an I/O callback,
setImmediate()always runs beforesetTimeout(0)(deterministic) -
Outside I/O callbacks,
setTimeout(0)vssetImmediate()order is non-deterministic - Never block the event loop — use streams for large files, worker threads for CPU work
-
Recursive
nextTick()starves I/O — usesetImmediate()for recursive async patterns -
Monitor your event loop — use
perf_hooks.monitorEventLoopDelay()in production
Connect with Me
If this helped you finally understand the event loop, follow along — I post visual explainers on Node.js, system design, and AI engineering every week.
- Twitter/X: @robinsingh — short threads on engineering concepts
- LinkedIn: Robin Singh — longer posts on system design and career
- GitHub: robins163 — code from all my posts
- Hashnode: unknowntoplay.hashnode.dev — full blog with diagrams
Next up: "I Built a RAG App That Chats With Any PDF — Here's How" — the full architecture, embedding pipeline, and code.
Top comments (0)