How the Event Loop works in Node.js
Event Loop is the mechanism that lets single-threaded Node.js handle concurrent I/O without blocking. It delegates work to the OS and libuv, then routes completed callbacks through six ordered phases per tick.
Theory
TL;DR
- Node.js is single-threaded but non-blocking because the event loop offloads I/O to libuv and the OS, then picks results up when they are ready
- Think of it like a waiter who places kitchen orders and keeps serving other tables instead of standing at the pass waiting
- Six phases per tick, fixed order: timers, pending callbacks, idle/prepare, poll, check, close
- Microtasks (
process.nextTickand Promises) drain completely between every phase transition, withnextTickahead of Promises - Block the main thread with a CPU loop and the entire phase cycle stalls
Quick example
const fs = require('fs');
console.log('1: sync');
process.nextTick(() => console.log('2: nextTick')); // microtask, highest priority
Promise.resolve().then(() => console.log('3: promise')); // microtask
setTimeout(() => console.log('4: timer'), 0); // timers phase
setImmediate(() => console.log('5: immediate')); // check phase
fs.readFile(__filename, () => {
setImmediate(() => console.log('6: I/O immediate')); // check, before next timers
setTimeout(() => console.log('7: I/O timer'), 0);
});
// Output: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7
// Inside the I/O callback, 6 always comes before 7Sync runs first. Then microtasks. Then the loop cycles through phases. Inside an I/O callback, setImmediate always fires before setTimeout(fn, 0) because the check phase follows poll directly.
How the event loop runs
V8 executes JavaScript synchronously on the main thread. When code calls fs.readFile or makes a network request, Node.js hands that work to libuv, a C library that ships with Node. libuv uses kernel-level async APIs (epoll on Linux, kqueue on macOS) for network I/O, and its own thread pool (4 threads by default, configurable via UV_THREADPOOL_SIZE) for file system, DNS, and crypto operations.
When an operation completes, libuv places the callback in the right phase queue. The event loop cycles through those queues in fixed order. One full cycle is called a tick.
The six phases of a tick
┌───────────────────────────┐
┌─>│ timers │ <- setTimeout, setInterval callbacks
│ └─────────────┬─────────────┘
│ │ <- microtasks (nextTick + Promises)
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ <- I/O callbacks deferred from last tick
│ └─────────────┬─────────────┘
│ │ <- microtasks
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ <- internal use only (libuv)
│ └─────────────┬─────────────┘
│ │ <- microtasks
│ ┌─────────────┴─────────────┐
│ │ poll │ <- retrieve new I/O events, execute I/O callbacks
│ └─────────────┬─────────────┘
│ │ <- microtasks
│ ┌─────────────┴─────────────┐
│ │ check │ <- setImmediate callbacks
│ └─────────────┬─────────────┘
│ │ <- microtasks
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ <- socket.on('close'), stream.on('close')
└───────────────────────────┘Timers: Runs setTimeout and setInterval callbacks whose delay has expired. setTimeout(fn, 0) means "run at the next timers phase, after at least 0ms" - not "run immediately." Under load, it often arrives later.
Pending callbacks: I/O callbacks deferred from the previous iteration. Certain TCP error notifications from the OS land here.
Poll: The center of the loop. Node.js retrieves completed I/O events from the OS and runs their callbacks. If the poll queue is empty and no setImmediate callbacks are registered, the loop waits here for new I/O - blocking intentionally. It moves on when a timer threshold is about to expire or a setImmediate was queued.
Check: setImmediate callbacks run here. Because check follows poll directly, setImmediate registered inside an I/O callback fires before the timers phase of the next tick.
Close callbacks: Cleanup handlers. socket.destroy() triggers socket.on('close', ...) in this phase.
Microtask queue priority
Between every phase transition, Node.js pauses to drain two queues in this order:
process.nextTickcallbacks- Promise
.then()callbacks
console.log('1: sync');
setTimeout(() => console.log('5: timer'), 0);
setImmediate(() => console.log('6: immediate'));
Promise.resolve().then(() => console.log('3: promise'));
process.nextTick(() => console.log('2: nextTick'));
console.log('4: sync end');
// Output:
// 1: sync
// 4: sync end
// 2: nextTick <- nextTick queue drains first
// 3: promise <- then Promise microtasks
// 5: timer <- timers phase (next tick)
// 6: immediate <- check phase (next tick)process.nextTick belongs to no phase. It fires when the current operation ends, before any phase runs. That makes it useful for callbacks that must run "after this sync block but before any I/O."
When to use
- HTTP APIs and REST servers: event loop handles thousands of concurrent connections on one thread
- Real-time apps with WebSockets: check phase keeps
setImmediateemits fast - CPU-heavy work (image resizing, cryptography): move off the main thread using
worker_threads - Iterative background tasks: use
setImmediateoverprocess.nextTickso I/O stays unblocked between iterations
Common mistakes
Assuming setTimeout(fn, 0) runs right after current code:
setTimeout(() => console.log('timer'), 0);
process.nextTick(() => console.log('nextTick'));
// Output: nextTick, then timer
// nextTick drains before the loop even reaches the timers phaseBlocking the loop with synchronous CPU work:
// Freezes all other requests until this finishes
app.get('/compute', (req, res) => {
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i;
res.send(String(sum));
});
// Fix: offload to worker_threads
const { Worker } = require('worker_threads');
app.get('/compute', (req, res) => {
const worker = new Worker('./compute-worker.js');
worker.on('message', (result) => res.send(String(result)));
});Recursive process.nextTick starves I/O:
// Dangerous: everything waits until this finishes
function recurse(n) {
if (n === 0) return;
process.nextTick(() => recurse(n - 1));
}
recurse(1000000); // I/O, timers, setImmediate all blocked
// Safe: yield to the loop each iteration
function recurse(n) {
if (n === 0) return;
setImmediate(() => recurse(n - 1));
}Non-deterministic order of setImmediate and setTimeout outside I/O:
// Order is timing-dependent here
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Order is guaranteed here
fs.readFile('/dev/null', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate')); // always first
});This one trips up seniors in interviews. The rule: inside an I/O callback, setImmediate always wins. Outside, the order depends on whether the timer threshold expired before the loop started its current tick.
I ran into the non-deterministic case in production once - a script assumed setImmediate always fired first, but it was registered outside any I/O callback. It passed locally and failed intermittently on the CI server.
Real-world usage
- Express.js: route handler callbacks run in the poll phase; DB queries hand off to libuv and return in a later poll iteration
- Socket.io: event emits queue in the check phase, keeping latency low without blocking new I/O
- Fastify: plugin initialization hooks into async loop phases to avoid blocking startup
- PM2 cluster mode: spawns one Node process per CPU core, each with its own event loop
- Node.js streams: readable/writable data events arrive through poll, enabling backpressure without blocking
Follow-up questions
Q: What are the six event loop phases in order?
A: Timers, pending callbacks, idle/prepare, poll, check, close. Microtasks (nextTick first, then Promises) drain between each transition.
Q: Why does setImmediate beat setTimeout(fn, 0) inside an I/O callback?
A: Inside an I/O callback the loop is in the poll phase. Next comes check, where setImmediate runs. The timers phase only arrives in the next tick.
Q: What happens if process.nextTick calls itself recursively?
A: The event loop never advances. All I/O callbacks, timers, and setImmediate calls block until the recursion ends. The Node.js docs explicitly warn against this.
Q: What does libuv's thread pool actually handle?
A: File system operations, DNS lookups, and crypto work. Network I/O does not go through the thread pool - it uses kernel async APIs directly. Default pool size is 4 threads, set UV_THREADPOOL_SIZE to change it.
Q: How is Node's event loop different from the browser's?
A: Browsers process tasks and microtasks but have no explicit phases like check or close. setImmediate is Node-only. Browsers also insert rendering steps between task batches.
Q: Senior question: you call setImmediate inside a poll callback. How many loop ticks pass before it runs?
A: Zero extra ticks. Check follows poll in the same tick. Microtasks between the two phases drain first, then the setImmediate callback runs. A second setImmediate registered inside that callback runs in the check phase of the next tick.
Examples
Basic: async execution order
console.log('A');
setTimeout(() => console.log('D: setTimeout'), 0);
Promise.resolve().then(() => console.log('C: Promise'));
process.nextTick(() => console.log('B: nextTick'));
console.log('E: sync end');
// Output:
// A
// E: sync end
// B: nextTick <- microtask, runs before any phase
// C: Promise <- microtask, after nextTick queue
// D: setTimeout <- timers phase, next tickSync code finishes first (A, E). Then the nextTick queue (B). Then Promise microtasks (C). Only then does the loop enter the timers phase (D).
setImmediate vs setTimeout inside and outside I/O
const fs = require('fs');
// Outside I/O callback - order is non-deterministic
setTimeout(() => console.log('outer timeout'), 0);
setImmediate(() => console.log('outer immediate'));
// Either order is valid here
// Inside I/O callback - order is fixed
fs.readFile('/dev/null', () => {
setTimeout(() => console.log('inner timeout'), 0);
setImmediate(() => console.log('inner immediate'));
// Always: inner immediate -> inner timeout
});Outside an I/O callback, setTimeout may have already passed the timers phase before setImmediate was even registered. Inside an I/O callback the loop is in poll, and check comes next. setImmediate wins, every time.
Starving the loop with recursive nextTick
// Dangerous: all I/O, timers, and setImmediate wait
function recursiveNextTick(count) {
if (count === 0) return;
process.nextTick(() => recursiveNextTick(count - 1));
}
recursiveNextTick(1000000);
// Safe: setImmediate lets the loop breathe
function recursiveImmediate(count) {
if (count === 0) return;
setImmediate(() => recursiveImmediate(count - 1));
}process.nextTick was built for small one-off deferrals within a phase. setImmediate is the right tool when you need to iterate in the background without blocking the server. The difference in production is a server that stays responsive vs one that queues all incoming requests until the recursion finishes.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.