Difficulty: Intermediate
The event loop is the core mechanism that allows Node.js to perform non-blocking I/O operations despite JavaScript being single-threaded. Understanding the event loop is critical for writing performant Node.js applications and is one of the most commonly asked topics in Node.js interviews. The event loop continuously checks whether the call stack is empty, and if it is, it picks up the next callback from the queue and pushes it onto the stack for execution.
The Node.js event loop (powered by libuv) operates in phases, and each phase has a FIFO queue of callbacks to execute. The main phases are: Timers (executes callbacks scheduled by setTimeout and setInterval), Pending Callbacks (executes I/O callbacks deferred from the previous loop iteration), Idle/Prepare (internal use only), Poll (retrieves new I/O events and executes I/O-related callbacks), Check (executes setImmediate callbacks), and Close Callbacks (executes close event callbacks like socket.on('close')). The loop cycles through these phases repeatedly.
Microtasks and macrotasks are two categories of asynchronous operations with different priority levels. Microtasks include process.nextTick callbacks and resolved Promise callbacks (.then, .catch, .finally, and async/await continuations). Macrotasks include setTimeout, setInterval, setImmediate, and I/O callbacks. The critical rule is: after each phase of the event loop (and after each macrotask), Node.js drains the entire microtask queue before moving on. Within microtasks, process.nextTick has higher priority than Promise callbacks.
The execution order follows this priority: (1) Synchronous code on the call stack, (2) process.nextTick callbacks, (3) Promise microtasks, (4) Macrotasks in the current event loop phase. This means process.nextTick can starve the event loop if called recursively, because Node.js will keep processing nextTick callbacks before moving to any I/O or timer callbacks. For this reason, queueMicrotask (which uses the Promise microtask queue) is often preferred over process.nextTick.
A common interview question is the execution order of setTimeout vs setImmediate. When called from within an I/O callback, setImmediate always executes before setTimeout(fn, 0) because after the Poll phase, the Check phase (setImmediate) runs before the loop wraps around to the Timers phase. However, when called from the main module (not inside an I/O callback), the order is non-deterministic because it depends on process performance and timer resolution.
console.log('1. Synchronous - start');
setTimeout(() => {
console.log('5. setTimeout (macrotask)');
}, 0);
setImmediate(() => {
console.log('6. setImmediate (macrotask)');
});
Promise.resolve().then(() => {
console.log('3. Promise.then (microtask)');
});
process.nextTick(() => {
console.log('2. process.nextTick (microtask, highest priority)');
});
queueMicrotask(() => {
console.log('4. queueMicrotask (microtask)');
});
console.log('1b. Synchronous - end');
Synchronous code runs first. Then all microtasks are drained (nextTick before Promises). Finally, macrotasks execute. The setTimeout vs setImmediate order may vary when called from the main module, but in this example setTimeout typically fires first due to timer scheduling.
const fs = require('fs');
// Inside an I/O callback, setImmediate ALWAYS runs before setTimeout
fs.readFile(__filename, () => {
console.log('Inside I/O callback');
setTimeout(() => {
console.log('setTimeout inside I/O');
}, 0);
setImmediate(() => {
console.log('setImmediate inside I/O');
});
});
// This order is guaranteed: setImmediate before setTimeout
// because after Poll phase, Check phase runs before Timers
Within an I/O callback, the order is deterministic. The Poll phase completes, then the Check phase runs (setImmediate), and only on the next loop iteration does the Timers phase run (setTimeout). This is a very common interview question.
// WARNING: process.nextTick can starve the event loop
let count = 0;
function recursiveNextTick() {
if (count < 5) {
count++;
console.log(`nextTick call #${count}`);
process.nextTick(recursiveNextTick);
}
}
setTimeout(() => {
console.log('setTimeout fires after all nextTick calls');
}, 0);
recursiveNextTick();
console.log('Synchronous code done');
All nextTick callbacks execute before the setTimeout callback, even though setTimeout was scheduled first. If the recursion had no limit, the setTimeout would never fire. This is called starvation. Use queueMicrotask or setImmediate for recursive patterns to avoid blocking the event loop.
Event Loop Phases, Microtasks, Macrotasks, setTimeout vs setImmediate, process.nextTick, Call Stack