The Node.js Event Loop -- Six Phases That Power Everything
The Node.js Event Loop -- Six Phases That Power Everything
LinkedIn Hook
"How can a single-threaded runtime handle 10,000 concurrent connections while your 'multi-threaded' server chokes on 200?"
The answer is the Node.js Event Loop -- the most misunderstood piece of JavaScript infrastructure in the industry. Every senior Node.js interview asks about it. Most candidates get it wrong.
They say "Node is single-threaded." Half-true. They say "setTimeout(fn, 0) runs immediately." Wrong. They say "process.nextTick is just a faster setImmediate." Dangerously wrong.
The truth is that Node's event loop runs in six distinct phases, each with its own queue, and between every phase it drains two microtask queues --
process.nextTickand Promises. Understanding the order in which these execute is the difference between debugging a race condition in 5 minutes and staring at it for 5 hours.In Lesson 1.2, I break down all six phases, the starvation traps, and the classic "what prints first" puzzles that interviewers love.
Read the full lesson -> [link]
#NodeJS #JavaScript #EventLoop #BackendDevelopment #InterviewPrep #SoftwareEngineering
What You'll Learn
- Why a single-threaded runtime can serve thousands of simultaneous connections
- The six phases of the Node.js event loop and what each one does
- The difference between
setImmediate,setTimeout(fn, 0), andprocess.nextTick - Microtasks vs macrotasks -- and how Node drains them between phases
- Why
setTimeoutandsetImmediateordering is non-deterministic outside I/O - How
process.nextTickcan starve the event loop if you abuse it - How to solve "what order do these log" interview puzzles confidently
The Airport Analogy -- Why One Worker Can Serve Thousands
Picture an airport with one very efficient ground controller and a radio. Planes are constantly landing, refueling, and taking off. A traditional server is like hiring one controller per plane -- if 1,000 planes arrive, you need 1,000 controllers, each mostly idle while waiting for their plane's fuel truck.
Node takes the opposite approach. A single controller sits at the tower with a clipboard divided into six columns. When a plane requests refueling, the controller writes "plane 42, needs fuel" on the clipboard, radios the ground crew, and immediately moves on to the next plane. When the ground crew finishes, they radio back -- the controller finds plane 42's entry and handles whatever comes next (taxi, takeoff, etc.). The controller never sits idle waiting. The slow work (fueling, baggage, catering) happens in the background by specialized crews.
That controller is the event loop. The clipboard columns are the six phases. The ground crews are libuv's thread pool and the OS kernel. JavaScript runs on one thread, but I/O runs on many -- which is why a single Node process can juggle tens of thousands of concurrent sockets without breaking a sweat.
+---------------------------------------------------------------+
| TRADITIONAL THREAD-PER-REQUEST MODEL |
+---------------------------------------------------------------+
| |
| Request 1 ---> Thread 1 (mostly waiting on DB) |
| Request 2 ---> Thread 2 (mostly waiting on DB) |
| Request 3 ---> Thread 3 (mostly waiting on DB) |
| ... |
| Request N ---> Thread N <-- N * ~1 MB stack = RAM death |
| |
| Problem: 10,000 idle threads still consume ~10 GB of RAM. |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| NODE.JS EVENT-LOOP MODEL |
+---------------------------------------------------------------+
| |
| Request 1 --\ |
| Request 2 ----> [ Single JS Thread + Event Loop ] |
| Request 3 --/ | |
| ... v |
| Request N [ libuv thread pool + kernel async I/O ] |
| |
| Result: 10,000 connections ~= one JS thread + a few workers |
+---------------------------------------------------------------+
The Six Phases -- A Guided Tour
Each iteration of the event loop (one "tick") walks through six phases in order. After every callback in any phase, Node drains the two microtask queues before running the next callback.
+----------------------------------------------------------------+
| THE NODE.JS EVENT LOOP |
+----------------------------------------------------------------+
| |
| +------------------+ |
| | 1. TIMERS | <-- setTimeout / setInterval callbacks |
| +------------------+ whose threshold has elapsed |
| | |
| v |
| +------------------+ |
| | 2. PENDING CBS | <-- deferred OS-level callbacks |
| +------------------+ (e.g. some TCP error codes) |
| | |
| v |
| +------------------+ |
| | 3. IDLE, PREPARE | <-- internal only (libuv housekeeping) |
| +------------------+ |
| | |
| v |
| +------------------+ |
| | 4. POLL | <-- retrieve new I/O events; execute |
| +------------------+ their callbacks; may block here |
| | |
| v |
| +------------------+ |
| | 5. CHECK | <-- setImmediate() callbacks |
| +------------------+ |
| | |
| v |
| +------------------+ |
| | 6. CLOSE CBS | <-- 'close' events, e.g. socket.on |
| +------------------+ |
| | |
| +----> back to phase 1 |
| |
| Between EVERY callback above: |
| a) drain process.nextTick queue (fully) |
| b) drain Promise microtask queue (fully) |
| |
+----------------------------------------------------------------+
Phase 1 -- Timers
Executes callbacks scheduled by setTimeout and setInterval whose time threshold has elapsed. Note "threshold" -- Node does not guarantee the callback fires at exactly n ms, only that it fires no earlier than n ms. If the poll phase runs long, your 10 ms timer might fire at 42 ms.
Phase 2 -- Pending Callbacks
Executes I/O callbacks deferred from the previous loop iteration. Most user code never touches this directly; it handles things like TCP errors that the OS reports after the fact.
Phase 3 -- Idle, Prepare
Internal libuv bookkeeping. You cannot schedule work here. It exists so libuv can prep the poll phase.
Phase 4 -- Poll
The most important phase. Node does two things here:
- Calculate how long to block waiting for new I/O.
- Process events in the poll queue (reading sockets, file descriptors, etc.).
If the poll queue is empty, Node may block here until a new event arrives -- unless setImmediate callbacks exist, in which case it immediately jumps to the check phase.
Phase 5 -- Check
Executes setImmediate callbacks. This phase exists specifically so you can schedule code to run right after the current poll phase completes, without waiting for the next iteration's timers.
Phase 6 -- Close Callbacks
Runs 'close' event handlers, e.g. socket.on('close', ...) when a TCP socket is destroyed.
Microtasks -- The Queues Between Phases
Node has two microtask queues that are drained after every individual callback in every phase:
process.nextTickqueue -- highest priority, drained first- Promise microtask queue --
.then,.catch,awaitcontinuations
Microtasks are not a phase. They run constantly, wedged between every macrotask. If you schedule a nextTick from inside a Promise .then, it runs before the next Promise. If you schedule a Promise from inside a nextTick, it runs after the current nextTick queue fully drains.
+----------------------------------------------------------------+
| CALLBACK EXECUTION ORDER (STRICT) |
+----------------------------------------------------------------+
| |
| 1. Run one macrotask callback (timer, I/O, immediate, ...) |
| 2. Drain ALL of process.nextTick queue |
| 3. Drain ALL of Promise microtask queue |
| 4. Go back to step 1 with the next macrotask |
| |
| If step 2 schedules more nextTicks -- they ALL run too |
| before moving to step 3. This is the starvation trap. |
| |
+----------------------------------------------------------------+
Code Example 1 -- setTimeout vs setImmediate (Outside I/O)
// File: timer-vs-immediate-top-level.js
// At the top level, ordering between setTimeout(fn, 0) and
// setImmediate is NON-DETERMINISTIC. It depends on how quickly
// the process starts relative to the 1ms timer threshold.
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
// Possible outputs:
// timeout
// immediate
// OR
// immediate
// timeout
//
// Why? setTimeout(fn, 0) is clamped to 1ms internally.
// If the loop reaches phase 1 before 1ms has elapsed, the timer
// is skipped that tick and 'immediate' (phase 5) fires first.
// If the loop takes longer than 1ms to start, the timer is ready
// by phase 1 and fires first.
Code Example 2 -- setTimeout vs setImmediate (Inside I/O)
// File: timer-vs-immediate-inside-io.js
// Inside an I/O callback, the ordering is GUARANTEED.
// setImmediate always fires before setTimeout(fn, 0).
const fs = require('fs');
fs.readFile(__filename, () => {
// We are now in the POLL phase (phase 4).
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
// Guaranteed output:
// immediate
// timeout
//
// Why? After this I/O callback finishes, the loop moves POLL -> CHECK,
// firing setImmediate next. Only after CHECK and CLOSE does it wrap
// back to phase 1 (TIMERS), where setTimeout finally runs.
Code Example 3 -- nextTick vs Promise vs Timer
// File: microtask-order.js
// Demonstrates the strict ordering of the two microtask queues
// relative to each other and to macrotasks.
console.log('1: sync start');
setTimeout(() => {
console.log('6: setTimeout');
}, 0);
setImmediate(() => {
console.log('7: setImmediate');
});
Promise.resolve().then(() => {
console.log('5: promise.then');
});
process.nextTick(() => {
console.log('3: nextTick A');
process.nextTick(() => {
// Scheduled from inside a nextTick -- still drained
// before Promise microtasks run.
console.log('4: nextTick B (nested)');
});
});
console.log('2: sync end');
// Output:
// 1: sync start
// 2: sync end
// 3: nextTick A
// 4: nextTick B (nested)
// 5: promise.then
// 6: setTimeout (or 7 first -- see Example 1)
// 7: setImmediate
Code Example 4 -- process.nextTick Starvation
// File: nexttick-starvation.js
// A classic interview trap. Because process.nextTick is drained
// BEFORE the loop moves to the next phase, an infinitely-recursive
// nextTick will prevent the event loop from EVER advancing.
// setTimeout, setImmediate, and I/O callbacks all starve.
const start = Date.now();
setTimeout(() => {
// This will NEVER print -- the event loop can't reach phase 1.
console.log('timer fired after', Date.now() - start, 'ms');
}, 100);
function recurse() {
process.nextTick(recurse);
}
recurse();
// Output: (nothing -- process hangs, CPU at 100%)
//
// Fix: use setImmediate(recurse) instead. setImmediate yields
// to the event loop between calls, so timers and I/O still run.
Common Mistakes
1. Believing setTimeout(fn, 0) runs "immediately" or "on the next tick".
It does neither. It is clamped to 1 ms and fires during the timers phase of a future loop iteration. If you want something to run as soon as the current operation finishes, use queueMicrotask or process.nextTick -- not a zero-delay timer.
2. Assuming setTimeout vs setImmediate order is deterministic at the top level.
It is not. The order depends on how fast the Node process starts relative to the 1 ms timer clamp. The only place the order is guaranteed is inside an I/O callback, where setImmediate always wins.
3. Using process.nextTick for "defer to later".
process.nextTick is not deferred -- it runs before any I/O or timers, before the loop even advances. Recursive or heavy nextTick usage starves the event loop entirely, making the process unresponsive. Use setImmediate for deferral.
4. Mixing up microtasks and macrotasks.
process.nextTick and Promises are microtasks -- drained between every callback. setTimeout, setImmediate, I/O, and close events are macrotasks -- each runs once per loop iteration in its respective phase. Confusing the two leads to off-by-one debugging nightmares.
5. Blocking the event loop with synchronous work.
A single JSON.parse on a 50 MB string, a bcrypt.hashSync, or a tight CPU loop blocks every phase. No timer fires, no socket accepts, no response is sent. Offload CPU work to worker_threads or break it into chunks with setImmediate.
Interview Questions
1. "Explain the six phases of the Node.js event loop in order."
The six phases run in a fixed cycle. First is timers, which fires expired setTimeout and setInterval callbacks. Second is pending callbacks, which handles deferred OS-level I/O callbacks (like certain TCP errors). Third is idle, prepare, used internally by libuv for housekeeping. Fourth is poll, the most important phase -- Node retrieves new I/O events from the kernel and runs their callbacks here, blocking if nothing else is pending. Fifth is check, which runs all setImmediate callbacks. Sixth is close callbacks, which runs handlers like socket.on('close', ...). After the close phase, the loop wraps back to timers. Between every individual callback in any phase, Node drains the process.nextTick queue first, then the Promise microtask queue, before picking up the next callback.
2. "What order do these log, and why?"
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
process.nextTick(() => console.log('D'));
console.log('E');
Output: A, E, D, C, B. First, the synchronous script runs top to bottom, printing A and E. Then, before the event loop advances to any phase, Node drains microtasks: process.nextTick first (prints D), then Promise microtasks (prints C). Finally, the loop enters the timers phase and fires the setTimeout callback (prints B). The key insight is that both microtask queues drain completely before any macrotask runs, and process.nextTick always wins over Promises.
3. "What will this print, and is the order guaranteed?"
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
Output: immediate, then timeout -- and the order is guaranteed. Because both callbacks are scheduled from inside an I/O callback, we are in the poll phase when they are registered. After the poll phase finishes the current callback, the loop moves forward to the check phase (firing setImmediate), then close callbacks, then wraps around to the timers phase (firing setTimeout). Outside an I/O callback, for example at the top level of a script, the order would be non-deterministic because the 1 ms clamp on setTimeout(fn, 0) may or may not have elapsed by the time the loop reaches phase 1.
4. "What is the difference between process.nextTick and setImmediate? When would you choose one over the other?"
Despite the misleading names, they are nearly opposite. process.nextTick is a microtask -- it fires before the event loop advances to the next phase, and its queue drains completely (including newly scheduled nextTicks) before anything else runs. setImmediate is a macrotask scheduled in the check phase, which fires after the poll phase of the current iteration. Use process.nextTick when you need to run something before any I/O or timer can possibly fire -- for example, emitting an error synchronously after returning from a constructor, or normalizing an API that might be sync or async. Use setImmediate when you want to yield control back to the event loop so timers and I/O can run -- for example, breaking a large CPU task into chunks, or recursively deferring work without starving the loop.
5. "How can a single-threaded Node process handle thousands of concurrent connections?"
The trick is that "single-threaded" only describes the JavaScript execution thread. Underneath, libuv manages a thread pool (default 4 threads) for operations the OS cannot do asynchronously (file I/O, DNS, crypto, compression), and uses the kernel's native async interfaces (epoll on Linux, kqueue on BSD/macOS, IOCP on Windows) for network sockets. When a request arrives, JavaScript runs briefly to kick off the I/O, registers a callback, and immediately returns to the event loop to handle the next event. The actual waiting -- for database responses, file reads, or network packets -- happens in the kernel or worker threads, never on the JS thread. Because each connection costs only a small closure and a file descriptor rather than an entire OS thread with a 1 MB stack, a single Node process can comfortably juggle tens of thousands of simultaneous connections on commodity hardware.
Quick Reference -- Event Loop Cheat Sheet
+----------------------------------------------------------------+
| THE SIX PHASES (IN ORDER) |
+----------------------------------------------------------------+
| |
| 1. TIMERS setTimeout / setInterval (expired) |
| 2. PENDING CBS deferred OS I/O callbacks |
| 3. IDLE, PREPARE libuv internal only |
| 4. POLL new I/O events; may block here |
| 5. CHECK setImmediate callbacks |
| 6. CLOSE CBS 'close' event handlers |
| |
| Between each callback: |
| a) drain process.nextTick queue |
| b) drain Promise microtask queue |
| |
+----------------------------------------------------------------+
+----------------------------------------------------------------+
| PRIORITY ORDER (HIGHEST TO LOWEST) |
+----------------------------------------------------------------+
| |
| 1. Synchronous code (current call stack) |
| 2. process.nextTick queue |
| 3. Promise microtask queue |
| 4. Timers phase (setTimeout / setInterval) |
| 5. Poll phase (I/O callbacks) |
| 6. Check phase (setImmediate) |
| 7. Close callbacks |
| |
+----------------------------------------------------------------+
| API | Queue Type | Phase | Fires Before Next... | Starvation Risk |
|---|---|---|---|---|
process.nextTick | Microtask | (between phases) | Any macrotask | Yes -- high |
Promise.then / await | Microtask | (between phases) | Any macrotask | Yes -- medium |
setTimeout(fn, 0) | Macrotask | Timers (1) | Poll, Check, Close | No |
setInterval | Macrotask | Timers (1) | Poll, Check, Close | No |
| I/O callback | Macrotask | Poll (4) | Check, Close | No |
setImmediate | Macrotask | Check (5) | Close, then next tick | No |
'close' handler | Macrotask | Close (6) | Next tick's Timers | No |
| Situation | setTimeout(fn, 0) vs setImmediate |
|---|---|
| Top level of a script | Non-deterministic (race with 1 ms clamp) |
| Inside an I/O callback | setImmediate always fires first |
Inside setImmediate | setTimeout fires on the next iteration |
Inside process.nextTick | Same as where nextTick was scheduled |
Prev: Lesson 1.1 -- What is Node.js Next: Lesson 1.3 -- libuv and the Thread Pool
This is Lesson 1.2 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.