Walk me through the phases of the Node event loop.
The loop cycles through timers, pending callbacks, poll, check and close phases; after every callback Node drains nextTick then promise microtasks, so nextTick beats promises and both beat any phase callback. setImmediate precedes the next timers phase only when scheduled inside a loop callback.
What the interviewer is scoring
- Whether you can name the phases in order rather than describing the loop as one undifferentiated queue
- Whether you separate the two microtask queues and know nextTick drains before promises
- Whether you recognise that setTimeout versus setImmediate at the top level is genuinely non-deterministic instead of asserting a fixed answer
- Whether you can define blocking in terms of a single callback occupying the thread, not in terms of "slow code"
- Whether you name a concrete measurement for loop lag rather than saying you would add logging
Answer
The phases
Each turn of the loop, libuv walks a fixed sequence of phases, and each phase has its own FIFO queue of callbacks. Timers runs callbacks whose setTimeout or setInterval threshold has elapsed. Pending callbacks runs a small set of system operations deferred from the previous iteration, typically TCP error callbacks. Idle and prepare are internal. Poll is where the loop actually waits: it computes how long it may block on the operating system for new I/O events, then executes the I/O completion callbacks it collected. Check runs everything scheduled with setImmediate. Close callbacks runs close handlers, such as a socket's close event.
Two queues sit outside that sequence entirely. The process.nextTick queue and the promise microtask queue are drained after the currently executing callback finishes, not at a phase boundary. Since Node 11 this happens after every individual callback, including between two timer callbacks in the same timers phase, so a microtask never has to wait for a whole phase to complete. nextTick is drained first and to exhaustion, then the microtask queue.
The dotted edges are the point: every phase drains the same two queues, rather than the drain happening once per turn of the loop.
flowchart TD
T[Timers] --> P[Pending callbacks]
P --> O[Poll - blocks for I/O]
O --> C[Check - setImmediate]
C --> L[Close callbacks]
L --> T
T -.-> M
P -.-> M
O -.-> M
C -.-> M
L -.-> M
M[nextTick to exhaustion then promise microtasks, after every single callback]The ordering that follows
Synchronous code runs to completion first, because nothing else can run while the single thread is inside it. Then nextTick, then promise continuations, then whichever phase comes next. That gives a reliable rule: process.nextTick beats Promise.then, and both beat anything scheduled through a timer, an I/O callback or setImmediate.
const fs = require('node:fs');
// Scheduling from inside an I/O callback makes the last two lines deterministic:
// we are already in the poll phase, so check (setImmediate) comes before
// the loop wraps around to timers again.
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
});
// sync
// nextTick
// promise
// immediate
// timeout
sync prints first because the callback body has not yielded yet. When it returns, Node drains nextTick and then microtasks, giving nextTick and promise. The loop is currently in poll, so the next phase it reaches is check, which fires immediate. Only after the loop wraps around to the next iteration does the timers phase fire timeout.
Why the top level is a trap
Move those four lines out of the readFile callback and into the top level of the module, and the relative order of timeout and immediate stops being guaranteed. The main module runs before the loop starts, so by the time the loop enters its first timers phase, whether the one-millisecond floor that setTimeout(fn, 0) is clamped to has already elapsed depends on how long process bootstrap took on that machine, on that run. If it has, timeout prints first; if it has not, the timers phase finds nothing due, and immediate wins in check. Candidates who confidently assert one fixed answer for the top-level case are wrong in a way that is easy to demonstrate, and the interviewer usually knows it. Saying "non-deterministic, and here is how to make it deterministic" is the stronger answer.
Blocking the loop
Blocking means one callback holds the single thread long enough that the loop cannot advance, so every pending timer, every queued I/O completion and every accepted-but-unread socket waits behind it. It is not about total CPU used; a service can burn a core steadily and stay responsive if the work is chopped into short callbacks. The characteristic causes are a synchronous filesystem or crypto call such as readFileSync or pbkdf2Sync on a request path, a JSON.parse over a multi-megabyte payload, a catastrophically backtracking regular expression, and an unbounded loop over a large array. A recursive process.nextTick is a particularly nasty variant: because the queue is drained to exhaustion before the loop can move on, it starves the loop without any phase callback ever appearing slow.
The symptom in production is that latency degrades across every endpoint at once, including health checks, rather than on the one route doing the heavy work. That fan-out is the diagnostic fingerprint.
To measure it, monitorEventLoopDelay from node:perf_hooks gives a histogram of loop lag, so you can alert on a percentile instead of a mean; performance.eventLoopUtilization() tells you what fraction of wall-clock time the loop spent busy rather than waiting, which distinguishes a saturated process from an idle one. For attribution, node --cpu-prof or an in-process profiler points at the specific synchronous frame, and --trace-sync-io warns when synchronous I/O happens after the first turn of the loop.
The event loop has one fixed phase order but two out-of-band microtask queues, and almost every ordering question reduces to remembering that nextTick and promises drain after every single callback rather than at a phase boundary.
Likely follow-ups
- In what order would a promise callback, a setTimeout, a setImmediate and a process.nextTick run?
- Why is setTimeout versus setImmediate non-deterministic in the main module but deterministic inside an I/O callback?
- What happens if a process.nextTick callback recursively schedules another nextTick?
- Where do worker threads help with a blocked loop and where do they not?
- How does an async function's await interact with the microtask queue compared to a .then chain?
Related questions
- Your Node service has low CPU but latency spikes for every request at the same moment. What is happening?mediumAlso on event-loop and performance4 min
- Walk me through what happens between the source you write and the instructions the CPU runs, and where does a JIT fit into that?mediumAlso on performance5 min
- Walk me through how you read a PostgreSQL execution plan.mediumAlso on performance5 min
- Field data says your page has an INP of 400 ms. How do you find the cause and fix it?hardAlso on event-loop4 min
- A query has an index on the filtered column but the plan shows a sequential scan. Walk me through how you would diagnose it.hardAlso on performance3 min
- How does the .NET garbage collector decide what to collect, and how would you make a hot path allocate less?hardAlso on performance6 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumAlso on async4 min
- A read-only endpoint that returns fifty thousand rows is slow and memory-heavy in EF Core. What is the context doing?mediumAlso on performance4 min