Understanding Node.js Event Loop Phases
The Node.js event loop is the core mechanism that enables asynchronous, non-blocking I/O operations despite JavaScript being single-threaded. Built on top of the Libuv library, the event loop continuously orchestrates the execution of callbacks, network events, and system operations across structured stages. This article breaks down the distinct phases of the Node.js event loop in the exact order they execute during backend execution, explaining what happens at each step and how microtasks interact with the lifecycle.
1. Timers Phase
The event loop cycle begins in the Timers phase. This phase executes
callbacks scheduled by setTimeout() and
setInterval().
The event loop checks if any timers have reached their specified threshold duration. If a timer has expired, its callback is invoked. Timers only specify the minimum threshold after which a callback can be executed, not the exact execution time, as subsequent phases and system load may introduce slight delays.
2. Pending Callbacks (I/O Callbacks) Phase
This phase executes system-level callbacks that were deferred from the previous loop iteration.
These typically involve low-level operations such as certain
operating system reports, TCP errors (for instance, receiving an
ECONNREFUSED error when attempting to connect a socket), or
pending stream operations that could not be processed immediately during
the poll phase.
3. Idle, Prepare Phase
The Idle and Prepare phase is strictly used internally by Node.js and Libuv.
Developers cannot directly schedule code to run in this phase. Node.js uses it to gather internal system state and prepare resources for the upcoming polling operations.
4. Poll Phase
The Poll phase is one of the most critical stages of the event loop. It serves two primary functions: 1. Calculating how long it should block and wait for new I/O events. 2. Processing events in the poll queue (such as incoming HTTP requests, database responses, and file system reads).
When the event loop enters the poll phase: * If the poll
queue is not empty: The event loop synchronously executes the
callbacks in the queue until the queue is exhausted or the system
execution limit is reached. * If the poll queue is
empty: * If scripts have been scheduled with
setImmediate(), the event loop ends the poll phase and
proceeds to the Check phase. * If no
setImmediate() scripts exist, the event loop waits for new
I/O callbacks to be added to the queue and executes them immediately.
However, if any timers have expired while waiting, the loop wraps around
to the Timers phase.
5. Check Phase
The Check phase allows developers to execute callbacks immediately after the poll phase completes.
This phase is dedicated exclusively to callbacks registered using
setImmediate(). If the poll phase becomes idle or detects
that setImmediate() callbacks are queued, it advances to
this phase to prevent I/O starvation.
6. Close Callbacks Phase
The final phase of the loop handles the cleanup of resources.
If a socket or handle is closed abruptly (e.g., calling
socket.destroy()) or emits a close event
(e.g., readableStream.on('close', ...)), its corresponding
cleanup callback is executed during this phase.
Microtasks:
process.nextTick() and Promises
Microtasks are not technically part of the Libuv event loop phases, but they heavily influence execution order. Microtask queues run immediately after the current operation finishes, before the event loop transitions to the next phase.
Microtasks are processed in two separate priority queues: 1.
process.nextTick() Queue: Has the highest
priority and drains completely before other microtasks. 2.
Promise Microtask Queue: Executes resolved Promise
callbacks (.then(), .catch(),
.finally(), and async/await continuations)
directly after the nextTick queue empties.