Libuv and Event Loop
Node.js utilizes libuv for its asynchronous, non-blocking operations. Understanding libuv's management of the event loop, callback queues, and thread pool is key to grasping Node.js internals.
User written synchronous Javascript code takes priority over async code that the runtime would like to execute. Only after the call stack is empty, the event loop comes into the picture.
To explain in an easy-to-understand way, if the code that is currently running is in the synchronous segment, the callback of the asynchronous codes that have just been executed will not be called until all synchronous codes (or empty callstack) are executed.
Core Components
- Event Loop: The heart of Node.js's asynchronous processing. It allows Node.js to handle non-blocking I/O operations efficiently, despite JavaScript being single-threaded.
- Callback Queue: Stores callback functions corresponding to completed asynchronous operations. The event loop processes this queue when the call stack is empty.
- Thread Pool: Libuv uses a thread pool to handle more intensive tasks that would otherwise block the event loop. Examples include file system operations (like
fs.readFile
) or cryptographic functions.
Tasks offloaded to libuv include:
- File system operations
- DNS lookups
- Network requests
How It Works 
When an asynchronous task (e.g., reading a file) is initiated:
- The task is offloaded to libuv.
- Libuv interacts with the operating system (OS) to perform the task (e.g., read data from disk).
- The V8 JavaScript engine continues executing other JavaScript code without waiting.
- Once the OS completes the task and returns data to libuv, libuv places the associated callback function into the appropriate callback queue.
- The event loop picks up this callback from the queue (when the call stack is free) and pushes it onto the call stack for execution.
This mechanism ensures Node.js remains non-blocking, even with many concurrent operations. Libuv maintains separate queues for different types of tasks (timers, I/O, setImmediate
) to manage priority.
Inside the Event Loop: Phases
The event loop processes tasks in a specific order through several phases:
- Timers Phase: Executes callbacks scheduled by
setTimeout()
andsetInterval()
. - Poll Phase:
- Retrieves new I/O events.
- Executes I/O-related callbacks (e.g., network requests, file system operations).
- If the event loop is idle and there are no scripts to execute, it will block here, waiting for new events.
- Check Phase: Executes callbacks scheduled by
setImmediate()
. These run immediately after the Poll phase completes. - Close Callbacks Phase: Executes close event callbacks (e.g.,
socket.on('close', ...)
).
Among the main phrases, the microtask queue will be called, inside it there are 2 more phrases:
Before transitioning to each main phase, the event loop processes microtasks.
process.nextTick()
callbacks: These have the highest priority among microtasks and are executed immediately after the current operation completes, before the event loop continues.- Promise callbacks (e.g.,
.then()
,.catch()
,.finally()
): These are also microtasks and are executed afterprocess.nextTick()
callbacks but before any other phase.
Timer, I/O, check, close are components of libuv, microtask queue is not components of libuv.
Execution Sequence
-
Microtask Queue
- Process.nextTick Queue: Callbacks registered with
process.nextTick()
execute first - Promise Queue: Then Promise callbacks (
.then()
,.catch()
, etc.) execute
- Process.nextTick Queue: Callbacks registered with
-
Timer Queue
- Callbacks scheduled by
setTimeout()
andsetInterval()
execute
- Callbacks scheduled by
-
Microtask Queue (again)
- If new microtasks were added, they execute now (nextTick, then Promises)
-
I/O Queue
- Callbacks from I/O operations (file system, network, etc.) execute
-
Microtask Queue (again)
- Any newly added microtasks execute
-
Check Queue
- Callbacks registered with
setImmediate()
execute
- Callbacks registered with
-
Microtask Queue (again)
- Any newly added microtasks execute
-
Close Queue
- Close event callbacks execute (e.g.,
socket.on('close', ...)
)
- Close event callbacks execute (e.g.,
-
Microtask Queue (final check)
- Any final microtasks execute
Loop Continuation
After completing all the steps above: - If there are new callbacks added to any queue, the Event Loop continues with another cycle - If there are no callbacks left to execute, the Event Loop exits
Idle Behavior
If the event loop is idle and there are no scripts to execute, it will block in the poll phase, waiting for new events. This is an important optimization:
- The event loop doesn't waste CPU cycles when nothing needs to be processed
- It efficiently waits for new I/O events, timers to expire, or callbacks to be scheduled
- Once new events arrive, the event loop "wakes up" and resumes processing
Key Insight
The microtask queue is checked and emptied between each phase of the Event Loop. This means microtasks have higher priority and can delay other operations if they keep adding new microtasks.
This prioritization ensures that certain types of asynchronous operations (particularly nextTick & Promises callbacks) complete as soon as possible, making the Node.js Event Loop both efficient and predictable.
Microtask Queue In Depth
nextTick Queue
The process.nextTick()
method adds callbacks to the nextTick queue, which has priority over the Promise queue:
- Callbacks in the nextTick queue are executed until the queue is completely empty
- If new callbacks are added to the nextTick queue during execution (via
process.nextTick()
), they will be executed in the same phase in FIFO order - The event loop will not proceed to the next phase until the nextTick queue is empty
Consider this example:
process.nextTick(() => {
console.log('Next tick 1');
process.nextTick(() => console.log('Next tick 1.1'));
});
setTimeout(() => console.log('Set timeout 1'));
The output will be:
This demonstrates that even though the callback 'Next tick 1.1' was added during execution, it still runs before the setTimeout callback.
🤝 Promise Queue
Promises are also part of the microtask queue but are processed after the nextTick queue:
- Callbacks from resolved Promises (using
.then()
or.catch()
) are added to the Promise queue - Similar to nextTick queue, if new Promise callbacks are added during execution, they will be processed in the same phase
Here's a comprehensive example:
Console 1
nextTick: 1
nextTick: 2
nextTick: 3
nextTick-nextTick: 2.2
Promise: 1
Promise: 2
Promise: 3
nextTick-Promise: 2.1
Promise-promise: 2.1
Promise-nextTick: 2.2
Timeout: 1
Execution Process Explained
- Synchronous code runs first (
Console 1
) - Event loop begins processing async code:
- First, all nextTick callbacks are processed:
- Prints
nextTick: 1
- Prints
nextTick: 2
and adds new callbacks to both Promise and nextTick queues - Prints
nextTick: 3
- Continues processing nextTick queue until empty (prints
nextTick-nextTick: 2.2
)
- Prints
- Then Promise callbacks are processed:
- Prints
Promise: 1
- Prints
Promise: 2
and adds new callbacks to Promise and nextTick queues - Prints
Promise: 3
- Continues with remaining Promise callbacks (prints
nextTick-Promise: 2.1
,Promise-promise: 2.1
)
- Prints
- Returns to nextTick queue since it has a new callback (prints
Promise-nextTick: 2.2
) - Only after both microtask queues are empty does it proceed to Timer queue
- Prints `Timeout: 1
- First, all nextTick callbacks are processed:
Important Note
If callbacks continually add new items to the nextTick or Promise queues, the event loop can get stuck in the microtask phase, preventing it from moving to other phases such as timers or I/O operations.
Best Practice
There are two main reasons to use process.nextTick
:
- To allow users to handle errors, clean up unneeded resources, or retry requests before the event loop continues
- To allow a callback to run after the call stack has unwound but before the event loop continues processing
I/O Queue and Timer Queue Interactions
Most async methods in Node.js built-in modules (like fs
, http
, etc.) push their callbacks into the I/O queue. According to the event loop execution order, the I/O queue has a lower priority than the Timer queue. However, there are some interesting behaviors to note:
<!-- Round 1 -->
Timeout: 2
Timeout: 3
Readfile 1
Timeout: 1
<!-- Round 2 -->
Readfile 1
Timeout: 2
Timeout: 3
Timeout: 1
<!-- Round 3 -->
Timeout: 2
Timeout: 3
Readfile 1
Timeout: 1
<!-- Round 4 -->
Readfile 1
Timeout: 2
Timeout: 3
Timeout: 1
When running this code multiple times, you may notice that the execution order between Timeout: 2
, Timeout: 3
, and Readfile 1
is not consistent. Why
Important Note on setTimeout(0)
When using setTimeout
with a delay of 0ms, it doesn't guarantee immediate execution. The mechanism works like this:
- The actual minimum delay for
setTimeout
is approximately 1 millisecond (implementation detail in the libuv library) - The timer's callback is only pushed to the Timer queue after this delay has passed
- I/O operations have varying completion times
This leads to two possible scenarios:
- Fast I/O Operation: If the I/O operation completes before the 1ms delay, and the event loop reaches the I/O phase before the Timer phase,
Readfile 1
will be printed first - Minimum Timer Delay Passed: If by the time the event loop starts, the 1ms delay has passed, the Timer queue callbacks will execute first, then the I/O queue callbacks
To demonstrate the guaranteed order (Timer queue before I/O queue), we can add CPU-intensive work to ensure the minimum timer delay has passed:
This example proves that under ideal conditions (when timers have had enough time to be added to the Timer queue), the I/O queue will execute after the Timer queue as expected in the event loop order.
I/O Polling Phase
Between the I/O queue and Check queue phases, there's a critical stage called I/O Polling. During this phase, Node.js (through libuv) checks whether async tasks have completed and, if so, pushes their callbacks into the I/O queue.
Consider this example:
Based on our understanding of the event loop flow and with the CPU-intensive loop, we might expect the output to be:
However, the actual output is often different:
Here's what's happening:
- When control reaches the I/O phase for the first time, there are no callbacks in the I/O queue yet
- During the I/O polling process, Node.js checks if
fs.readFile
has completed - Even if
fs.readFile
completes during this check, its callback is pushed to the I/O queue after the current phase has already been passed - Control then moves to the Check queue, executing the
setImmediate
callback - In the next iteration of the event loop, the I/O phase processes the pending
fs.readFile
callback
This explains why Readfile: 1
is printed last, despite I/O having higher priority than the Check queue in the event loop order.
Check Queue
The Check queue handles callbacks registered with setImmediate()
. Like other phases, during execution of Check queue callbacks, the microtask queues are processed between each callback:
The output will be:
This demonstrates that even though the nextTick
callback is added after the second setImmediate
callback, it executes first because the microtask queue is processed after each Check queue callback.
Close Queue
The Close queue is the final queue in the event loop and handles callbacks related to close events from asynchronous operations. For example:
The output follows our expected event loop order:
This confirms that the Close queue is processed last in the event loop cycle, after all other queues.
Q1: Event Loop Execution Order
Output Analysis:
Explanation:
-
Synchronous Code Execution:
printA()
executes immediately, logging:a= 100
- The last
console.log
executes, logging:Last line of the code
-
Event Loop Begins:
- Microtask Queue: Empty in this example
- Timer Queue:
setTimeout
callback executes, logging:Timer expired
- I/O Polling & Queue: The
fs.readFile
operation is checked, but likely hasn't completed yet - Check Queue:
setImmediate
callback executes, logging:setImmediate
- I/O Queue (Next Iteration): When the file reading completes, its callback executes, logging:
File reading callback
Q2: Event Loop with Promise and nextTick
Output Analysis:
a= 100
Last line of the code
process.nextTick()
Promise resolved
Timer expired
setImmediate
File reading callback
Explanation:
-
Synchronous Code Execution:
printA()
executes immediately, logging:a= 100
- The last
console.log
executes, logging:Last line of the code
-
Event Loop Begins:
- Microtask Queue:
- First,
nextTick
callbacks execute:process.nextTick()
- Then, Promise callbacks execute:
Promise resolved
- First,
- Timer Queue:
setTimeout
callback executes, logging:Timer expired
- I/O Polling & Queue: The
fs.readFile
operation is checked, but likely hasn't completed yet - Check Queue:
setImmediate
callback executes, logging:setImmediate
- I/O Queue (Next Iteration): When the file reading completes, its callback executes, logging:
File reading callback
- Microtask Queue:
Q3: Complex Event Loop with Nested Callbacks
🔍 Output Analysis:
Last line of the code
process.nextTick()
Promise resolved
1st Timer expired
setImmediate
File reading callback
2nd process.nextTick()
2nd setImmediate
2nd Timer expired
Explanation:
The fs.readFile
callback itself executes as part of the Poll phase (handling I/O events). Once that callback finishes:
-
Microtasks from
fs.readFile
callback:2nd process.nextTick()
runs immediately after the "File reading callback" log. -
Event Loop Continues to Check Phase (Current Cycle): The event loop doesn't start a completely new cycle from Timers immediately. It first proceeds to the Check phase of the current event loop cycle. This is where callbacks scheduled by
setImmediate
are executed. So, the "2nd setImmediate" (which was scheduled inside thefs.readFile
callback) runs here. -
Timers Phase (Next Cycle): Only after the current cycle's Poll phase, its microtasks, and its Check phase are complete will the event loop begin a new cycle. The "2nd Timer expired" (which was also scheduled inside fs.readFile) will be processed in the Timers phase of this next event loop cycle.
Q4: Nested nextTick and Promise Chain
🔍 Output Analysis:
Last line of the code
process.nextTick()
Inner process.nextTick()
Promise resolved
Timer expired
setImmediate
File reading callback
Explanation:
- Synchronous Code Execution:
-
The code logs:
Last line of the code
-
Event Loop Begins:
- Microtask Queue:
- First
process.nextTick()
callback executes, logging:process.nextTick()
- This schedules an inner nextTick, which executes immediately since we're still in the microtask phase:
Inner process.nextTick()
- Then Promise callback executes:
Promise resolved
- First
- Timer Queue:
Timer expired
logs - I/O Polling & Queue: File reading checked but not yet complete
- Check Queue:
setImmediate
logs - I/O Queue (Next Iteration): When the file reading completes, its callback executes, logging:
File reading callback
- Microtask Queue:
Key Takeaways from These Examples
-
Execution Order Priority:
- Synchronous code executes first
- Microtasks (nextTick then Promises) execute between each phase of the event loop
- Timer callbacks execute before I/O callbacks (under ideal conditions)
- setImmediate callbacks execute after I/O operations
-
Nested Callbacks:
- When callbacks contain their own async operations, these are scheduled for future event loop phases
- The microtask queue is always checked after each callback completes, before moving to the next phase
-
nextTick Behavior:
process.nextTick()
callbacks can create a chain of operations that all execute before moving to the next phase- This can potentially block the event loop if not used carefully