The Node.js event loop isn’t a single queue, but a complex, multi-phase process that handles asynchronous operations by cycling through distinct stages.

Let’s watch it in action. Imagine this simple Node.js script:

console.log('Start');

setTimeout(() => {
  console.log('Timeout 1');
}, 0);

setImmediate(() => {
  console.log('Immediate 1');
});

Promise.resolve().then(() => {
  console.log('Promise 1');
});

process.nextTick(() => {
  console.log('Next Tick 1');
});

console.log('End');

When you run this, you’ll see:

Start
End
Next Tick 1
Promise 1
Timeout 1
Immediate 1

Notice Timeout 1 (from setTimeout(..., 0)) runs after Immediate 1 (from setImmediate). This is a direct consequence of the event loop’s phases.

Node.js uses an event loop to manage I/O operations and other asynchronous tasks without blocking the main thread. Instead of waiting for an operation to complete, Node.js registers a callback and moves on. When the operation finishes, the callback is placed in a queue associated with a specific phase of the event loop, and the loop processes these queues in a defined order.

The event loop has several distinct phases, each responsible for handling different types of events:

  1. Timers: This phase executes callbacks scheduled by setTimeout() and setInterval(). The loop checks if any timers have expired and, if so, executes their callbacks.
  2. Pending Callbacks: Executes I/O callbacks that were deferred to the next loop iteration. This phase is less commonly used directly by developers.
  3. Idle, Prepare: Used internally by Node.js.
  4. Poll: This is a crucial phase. It retrieves new I/O events and executes their callbacks. It will also block if necessary, waiting for new events. If timers are ready, the loop will transition back to the timers phase. If setImmediate() callbacks are scheduled, the loop will move to the check phase.
  5. Check: Executes callbacks scheduled by setImmediate().
  6. Close Callbacks: Executes callbacks for closed connections, e.g., socket.on('close', ...).

Crucially, the event loop also has two special queues that are processed between phases:

  • Microtasks: These are tasks with higher priority than regular callbacks. They are executed after the current operation completes and before the event loop moves to the next phase. Promise.resolve().then() callbacks and process.nextTick() callbacks (though nextTick is technically not a microtask in the V8 sense, it behaves similarly in Node.js, executing immediately after the current operation, before any I/O or timers) are added to this queue.
  • Macrotasks: These are the callbacks associated with the event loop phases themselves (timers, I/O, setImmediate, etc.).

In our example:

  • console.log('Start'); and console.log('End'); run immediately because they are synchronous.
  • process.nextTick(() => { console.log('Next Tick 1'); }); is placed in the nextTick queue. It runs after the current synchronous code finishes but before any I/O or timers are processed.
  • Promise.resolve().then(() => { console.log('Promise 1'); }); is placed in the microtask queue. It also runs after the current synchronous code finishes, and generally after nextTick callbacks.
  • setTimeout(() => { console.log('Timeout 1'); }, 0); schedules a callback for the Timers phase. Even with a 0ms delay, it must wait for the Timers phase to be entered in a subsequent loop iteration.
  • setImmediate(() => { console.log('Immediate 1'); }); schedules a callback for the Check phase. This phase runs after the Poll phase.

Because setTimeout(..., 0) and setImmediate(...) are scheduled, the event loop will proceed through its phases. The synchronous code runs first. Then, the nextTick and microtask queues are emptied. After that, the loop enters the Poll phase, and if there’s nothing else to do, it might check for timers. Since setTimeout was scheduled, its callback is executed when the Timers phase is eventually reached. Finally, the loop moves to the Check phase, executing the setImmediate callback. The key is that setImmediate’s phase (Check) comes after the Poll phase, and the Poll phase typically runs after the Timers phase has had a chance to execute expired timers. This is why Immediate 1 can run after Timeout 1 when both are scheduled with a 0ms delay.

The one thing most people don’t realize is how process.nextTick and Promise.then interact. While both execute after the current operation and before the next phase, process.nextTick callbacks are processed before microtasks. This is why Next Tick 1 consistently appears before Promise 1 in the output.

Understanding these phases and the microtask/macrotask queues is critical for writing efficient, non-blocking Node.js applications, especially when dealing with complex asynchronous workflows.

The next concept to explore is how different types of I/O operations are handled across these phases.

Want structured learning?

Take the full Nodejs course →