How the Event Loop works in Node.js
Event Loop β the mechanism that allows single-threaded Node.js to handle thousands of concurrent I/O operations without blocking, by delegating work to the OS and libuv's thread pool, then scheduling callbacks through a series of ordered phases.
Theory
Why Node.js Needs the Event Loop
JavaScript is single-threaded β one call stack, one thing at a time. Without additional machinery, a single database query or file read would freeze the entire server until it completed.
Node.js solves this by never waiting. Instead of blocking, it offloads I/O work to the operating system or libuv's thread pool, and the event loop brings the results back into JavaScript when they're ready.
This is one of the most common deep-dive interview questions for Node.js roles β interviewers expect you to explain phases, not just say "it handles async code."
How the Event Loop Runs
The event loop is a C program inside libuv that continuously checks: "Is there work to do?" Each iteration is called a tick. Within a tick, callbacks are processed in a fixed sequence of phases.
Between every phase transition, Node.js drains two microtask queues β in this order:
process.nextTick()callbacks (highest priority in Node.js)- Promise callbacks (
.then(),async/awaitresolutions)
Microtasks always run to completion before the next phase begins.
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')
βββββββββββββββββββββββββββββPhase-by-Phase Breakdown
Timers phase: Executes callbacks whose setTimeout or setInterval delay has expired. setTimeout(fn, 0) doesn't mean "run immediately" β it means "run at the next timers phase after at least 0ms." Under load, actual execution may be delayed further.
Pending callbacks phase: Runs I/O callbacks that were deferred to the next loop iteration β for example, certain TCP error callbacks from the OS.
Poll phase: The heart of the event loop. Node.js retrieves completed I/O events from the OS and executes their callbacks. If no callbacks are ready, the event loop may block here and wait β unless:
- There are
setImmediatecallbacks registered (it proceeds to check immediately) - A timer threshold is about to expire (it waits only until that point)
Check phase: Executes setImmediate callbacks. These always run after I/O events in the current tick β which is why setImmediate inside an I/O callback fires before setTimeout(fn, 0).
Close callbacks phase: Handles cleanup callbacks like socket.destroy() β socket.on('close', ...).
Execution Order: A Full Example
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Output (always):
// immediate
// timeoutInside an I/O callback, setImmediate always fires before setTimeout(fn, 0). The check phase comes before the timers phase in the next tick.
Outside an I/O callback, the order is non-deterministic β it depends on whether the timer threshold expired before the event loop started its first tick.
Microtask Queue Priority
console.log('1: sync start');
setTimeout(() => console.log('5: setTimeout'), 0);
setImmediate(() => console.log('6: setImmediate'));
Promise.resolve().then(() => console.log('3: Promise'));
process.nextTick(() => console.log('2: nextTick'));
console.log('4: sync end');
// Output:
// 1: sync start
// 4: sync end
// 2: nextTick β nextTick queue drains first
// 3: Promise β then Promise microtask queue
// 5: setTimeout β timers phase (next tick)
// 6: setImmediate β check phase (next tick)process.nextTick fires before Promises β not because of a phase, but because Node.js drains the nextTick queue before the Promise microtask queue.
Key Rules
- The event loop processes phases in strict order: timers β pending β idle β poll β check β close
- Microtasks (
nextTickand Promises) drain completely between every phase transition process.nextTickhas higher priority than Promise callbacks β it drains firstsetImmediatevssetTimeout(fn, 0): inside an I/O callback,setImmediatealways wins; outside, it's non-deterministic- The poll phase can block the loop if there's nothing to do β this is intentional, it's waiting for I/O
- Blocking the main thread (CPU-heavy loops,
fs.readFileSync) prevents the event loop from processing any other callbacks
Common Misconceptions
"setTimeout(fn, 0) runs immediately after the current code." It does not. It schedules for the timers phase of a future tick. process.nextTick runs much sooner β after the current operation but before any I/O or timer.
setTimeout(() => console.log('timer'), 0);
process.nextTick(() => console.log('nextTick'));
// Output: nextTick, then timer"setImmediate is the same as setTimeout(fn, 0)." They both defer execution, but to different phases. setImmediate always fires in the check phase β after poll I/O. setTimeout(fn, 0) fires in the timers phase β before poll. Inside an I/O callback, setImmediate consistently fires first.
"async/await pauses the entire Node.js process." await pauses only the current async function. The event loop continues processing other callbacks, I/O events, and timers while the awaited operation runs.
Connections to Other Concepts
The event loop is the reason Promise callbacks run before setTimeout even with a zero delay β Promises are microtasks, timers are macrotasks. This distinction is core to understanding any async Node.js code.
In HTTP servers, each incoming request arrives as an I/O event in the poll phase. All middleware and route handlers run synchronously within that callback β any blocking code here stalls all other requests.
worker_threads and child_process exist specifically to move CPU-heavy work off the main event loop thread, preserving server responsiveness.
Examples
Basic: Reading order of async operations
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
// C: Promise
// D: setTimeoutSynchronous code runs first (A, E). Then the nextTick queue (B). Then Promise microtasks (C). Only then does the event loop advance to the timers phase (D).
setImmediate vs setTimeout inside I/O
const fs = require('fs');
// Outside I/O callback β order is non-deterministic
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Could be: timeout, immediate OR immediate, timeout
// Inside I/O callback β order is always deterministic
fs.readFile('/dev/null', () => {
setTimeout(() => console.log('I/O timeout'), 0);
setImmediate(() => console.log('I/O immediate'));
// Always: I/O immediate, then I/O timeout
});Outside an I/O callback the timers phase may have already passed before setImmediate was registered, making the order timing-dependent. Inside an I/O callback, execution is already in the poll phase β the next phase is check, not timers. So setImmediate always fires first.
Starving the event loop with recursive nextTick
// Dangerous: recursive nextTick starves all I/O
function recursiveNextTick(count) {
if (count === 0) return;
process.nextTick(() => recursiveNextTick(count - 1));
}
recursiveNextTick(1000000);
// I/O callbacks, timers, and setImmediate are all blocked
// until all 1,000,000 nextTick callbacks complete
// Safe alternative: setImmediate yields to the event loop each iteration
function recursiveImmediate(count) {
if (count === 0) return;
setImmediate(() => recursiveImmediate(count - 1));
}The Node.js documentation explicitly warns against recursive nextTick. It was designed for deferring small operations within the current phase β not for recursion. Using setImmediate allows the event loop to process I/O between iterations.
This pattern appears in production when developers try to "defer" heavy computation using nextTick β the result is a server that stops responding to requests until the computation finishes.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.