The Node.js Event Loop: Beyond the Basics
Most explanations of the event loop are oversimplified. Here's what actually happens, phase by phase, and why it matters.
The Node.js Event Loop: Beyond the Basics
“The event loop checks the callback queue and pushes callbacks to the call stack.” This common explanation is incomplete. The event loop has six phases, and understanding them explains behaviors that otherwise seem like bugs.
The Six Phases
┌───────────────────────────┐
┌─>│ timers ��� setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ I/O callbacks deferred to next loop
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ internal use only
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ retrieve new I/O events
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ socket.on('close', ...)
│ └───────────────────────────┘
Timers Phase
setTimeout and setInterval callbacks execute here. But there’s a catch:
setTimeout(() => console.log("timer"), 0);
“0ms” doesn’t mean “immediately.” It means “as soon as the timers phase runs, if at least 0ms have passed.” The actual minimum delay is 1ms (clamped by libuv).
Poll Phase: Where Node Spends Most Time
The poll phase does two things:
- Calculates how long it should block waiting for I/O
- Processes events in the poll queue
This is where file reads, network responses, and database callbacks land. If nothing else is scheduled, Node blocks here waiting for I/O.
The Microtask Queue
Between every phase, Node processes all microtasks:
Promise.resolve().then(() => console.log("microtask"));
process.nextTick(() => console.log("nextTick"));
setTimeout(() => console.log("timer"), 0);
setImmediate(() => console.log("immediate"));
Output:
nextTick
microtask
timer (or immediate — order depends on context)
immediate (or timer)
process.nextTick runs before Promises. Both run before any phase callback.
Why This Matters: Real Bugs
Bug 1: Starving the Event Loop
// ❌ This blocks the event loop
app.get("/compute", (req, res) => {
const result = fibonacci(45); // CPU-bound, blocks everything
res.json({ result });
});
While fibonacci(45) runs, no other requests can be handled. No timers fire, no I/O completes.
Fix: offload to a worker thread:
import { Worker } from "node:worker_threads";
app.get("/compute", async (req, res) => {
const result = await runInWorker(() => fibonacci(45));
res.json({ result });
});
Bug 2: setTimeout vs setImmediate
// Inside an I/O callback, setImmediate always runs first
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
// Always outputs:
// immediate
// timeout
Because after I/O, the event loop is in the poll phase. The next phase is check (setImmediate), not timers (setTimeout).
Bug 3: nextTick Starvation
// ❌ This prevents any I/O from processing
function loop() {
process.nextTick(loop);
}
loop();
nextTick runs between phases — if you keep adding nextTick callbacks, the event loop never advances. Use setImmediate instead for recursive scheduling.
Measuring Event Loop Lag
let lastCheck = Date.now();
setInterval(() => {
const now = Date.now();
const lag = now - lastCheck - 1000; // Expected 1000ms interval
if (lag > 50) {
logger.warn(`Event loop lag: ${lag}ms`);
}
lastCheck = now;
}, 1000);
If the interval consistently fires late, something is blocking the loop.
Practical Rules
- Never do CPU work on the main thread — use worker threads
- Prefer
setImmediateoverprocess.nextTickfor recursive operations - Monitor event loop lag in production
- Batch database operations — 1 query with 100 results beats 100 queries
- Stream large payloads — don’t buffer entire files in memory