PUBLISHED APRIL 09, 2024
Understanding NodeJS Event loop
Exploring the Node.js event loop by understanding its phases, kernel integration, and processes enabling seamless handling of asynchronous operations in your applications.
Prerequisite
A basic understanding of JavaScript timers and asynchronous programming concepts would be beneficial for grasping the Node.js event loop intricacies discussed here in.
Introduction to Event Loop
Node.js relies on an event-driven, non-blocking architecture, powered by its event loop. Understanding the event loop is vital for nodeJS developers to build efficient and scalable applications. Let's dive in!
In nodeJS, the event loop is tightly integrated with the underlying system kernel, which manages the system's resources and provides interfaces for performing I/O operations.
When nodeJS needs to perform I/O operations, such as reading from a file or making a network request, it delegates these tasks to the system kernel.
When nodeJS starts, it initializes the event loop, processes the provided input script (or drops into the REPL, ) which run async operations, schedule timers, or call `process.nextTick()`, then begins processing the event loop, which continuously monitors the execution stack and the task queue.
As JavaScript code is executed, it may initiate asynchronous operations, such as reading from a file or making a network request. Instead of waiting for these operations to complete, Node.js adds them to the task queue and continues executing the remaining code.
Once the execution stack is empty, Node.js checks the task queue for any pending tasks. If there are tasks in the queue, Node.js retrieves them one by one and processes them in a sequential manner.
These tasks may involve performing I/O operations, waiting for timers to expire, or handling callbacks from previous asynchronous operations.
After processing all tasks in the queue, nodeJS may return to the event loop or exit it altogether, depending on the application's lifecycle. Exiting the event loop typically occurs when the application shuts down or no longer has any pending tasks to process.
Before we go visiting the eventloop phases, let have a short introduction with an illustrative diagram.
The event loop has several phases, each with its own queue of callbacks. When the event loop enters a phase, it performs phase-specific operations and executes callbacks in that phase's queue until the queue is empty or the maximum callback limit is reached. Then, it moves to the next phase and repeats the process.
Also according to the nodejs documentation ,
There is a slight discrepancy between the Windows and the Unix/Linux implementation, but that's not important for this demonstration. The most important parts are here. There are actually seven or eight steps, but the ones we care about — ones that nodeJS actually uses - are those above.
Next up, lets dive into to each event loop phases and try to understand the magic involved!
Phases Overview
1. Timers Phase:
- ➢Executes callbacks scheduled by
setTimeout()
andsetInterval()
. - ➢Callbacks scheduled with a delay of
0
(setTimeout(callback, 0))
are also processed in this phase.
2. Pending Callbacks Phase:
- ➢Executes I/O callbacks deferred to the next loop iteration.
- ➢Callbacks from asynchronous operations like file I/O, network requests, or timers that have completed are processed here.
3. Idle and Prepare Phases:
- ➢These phases are used internally by Node.js and are not typically exposed to developers.
4. Poll Phase:
- ➢Retrieves new I/O events from the operating system's kernel.
- ➢Executes I/O related callbacks, such as those from file I/O, network operations, or timers.
- ➢NodeJS may block in this phase if there are no pending I/O operations.
5. Check Phase:
- ➢Invokes
setImmediate()
callbacks. - ➢
setImmediate()
callbacks are executed after the poll phase, but before any I/O callbacks.
6. Close Callbacks Phase:
- ➢Executes close callbacks, such as those registered with
socket.on('close', ...)
. - ➢These callbacks are typically associated with closing network sockets, file descriptors, or other resources.
Detail overview of each phase
Timers Phase
The timers phase manages `setTimeout()` and `setInterval()` callbacks. These timers specify a threshold after which their provided callbacks may be executed.
- Description: Timer callbacks will run as early as possible after the specified amount of time has passed, but Operating System scheduling or the running of other callbacks may cause delays.
- Execution Order: This phase is the first phase of the event loop and begins the cycle.
- Trigger: The event loop schedules timer callbacks based on their specified timeout or interval.
- Behavior: When a timer's threshold is reached, its callback is added to the callback queue. However, the actual execution of the timer callback may be delayed if other operations are currently being processed by the event loop. The poll phase controls when timers are actually executed.
const fs = require('node:fs');
function someAsyncOperation(callback) {
fs.readFile('./play.json', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
someAsyncOperation(() => {
const startCallback = Date.now();
while (Date.now() - startCallback < 10) {
// do nothing
}
console.log('done with asyncOperation!')
});
Breakdown:
- When the event loop enters the poll phase, it waits for the specified time threshold for the soonest timer.
- If asynchronous operations complete before the timer expires, their callbacks are added to the poll queue.
- Once the timer's threshold is reached and there are no pending callbacks, the event loop executes the timer's callback.
Some quick fun for you:
Update the delay for the `setTimeout()` function to 1ms.
Execute the code multiple times and you will notice that, depending on how fast the fs.readFile
operation is, the logs order will alternate.
Pending Callbacks Phase
- ➢System Operation Callbacks: This phase handles callbacks for specific system operations, such as TCP errors.
- ➢Error Handling Delays: For instance, on certain *nix systems, when a TCP socket encounters errors like ECONNREFUSED during connection attempts, reporting the error is deferred.
Poll Phase
The poll phase of the Node.js event loop serves two primary purposes:
1. Calculating Blocking and Polling Time: This phase determines the duration for which it should block and poll for I/O operations.
2. Processing Poll Queue Events: It processes events in the poll queue, which primarily consists of I/O-related callbacks.
Here's a breakdown of what happens in the poll phase:
- ➢When the event loop enters the poll phase and no timers are scheduled, it performs one of two actions based on the state of the poll queue:
- ➢If the poll queue is not empty, the event loop iterates through its queue of callbacks, executing them synchronously. This process continues until either the queue is exhausted or it reaches a system-dependent hard limit.
- ➢If the poll queue is empty, the event loop checks for additional tasks:
- ➢If scripts have been scheduled by `setImmediate()`, the event loop concludes the poll phase and proceeds to the check phase to execute those scheduled scripts.
- ➢If no scripts are scheduled by `setImmediate()`, the event loop waits for callbacks to be added to the queue. Once callbacks are available, it executes them immediately.
After processing the poll queue, if the queue becomes empty, the event loop checks for timers that have reached their time thresholds. If one or more timers are ready to execute, the event loop transitions back to the timers phase to execute their callbacks.
Check phase
The check phase facilitates the immediate execution of callbacks after the completion of the poll phase. Here's a detailed breakdown:
- ➢Immediate Execution: In the check phase, callbacks scheduled with `setImmediate()` are executed promptly once the poll phase finishes. This allows for immediate handling of tasks without waiting for the next tick of the event loop.
- ➢Transition from Poll to Check: If the event loop enters the poll phase and becomes idle without any pending I/O operations, it transitions to the check phase if scripts have been queued with `setImmediate()`. This ensures that queued callbacks are processed promptly without delay.
- ➢Special Timer: `setImmediate()` operates as a special timer within the event loop, utilizing a libuv API to schedule callbacks for execution after the completion of the poll phase.
- ➢Usage: Developers often utilize `setImmediate()` for handling time-sensitive tasks or responding to events as soon as possible within the event loop. By leveraging this mechanism, they can ensure timely execution of critical operations without unnecessary delays.
Close Phase
In the close callbacks phase, the `close` event is emitted when a socket or handle is abruptly closed, such as with socket.destroy()
.
Let's see what happens here:
- ➢Close Event Emission: When a socket or handle is closed suddenly, triggering events like 'close', they are emitted in this phase of the event loop.
- ➢Abrupt Closure Handling: If a socket or handle is closed unexpectedly, causing an abrupt shutdown, the corresponding 'close' event is emitted during this phase.
- ➢Fallback to process.nextTick(): If the close event isn't emitted due to an abrupt closure, it will be deferred and emitted via `process.nextTick()` instead.
This phase ensures that applications can react to unexpected closures and perform necessary cleanup tasks, maintaining robustness and reliability.
Before concluding with our discussion, lets explore nodejs timers and compare them.
setTimeout
`setTimeout` is a function provided by Node.js and browser environments to schedule the execution of a function after a specified delay. It takes two arguments: a callback function to execute and a delay time in milliseconds.
Behavior:
- ➢The callback function provided to `setTimeout` is added to the event queue after the specified delay.
- ➢It does not guarantee that the callback will execute precisely after the specified delay. Other tasks in the event loop may delay its execution.
- ➢The delay is a minimum threshold, meaning the callback may not execute immediately after the specified time if the event loop is busy.
Use Cases:
- ➢Delayed execution of tasks, such as animations or timed events.
- ➢Implementing timeouts for asynchronous operations.
setImmediate
`setImmediate` is a Node.js-specific function that schedules the immediate execution of a callback function in the next iteration of the event loop.
Behavior:
- ➢The callback provided to `setImmediate` is executed in the next iteration of the event loop, `after I/O events and before any timers`.
- ➢It ensures that the callback is executed as soon as possible after the current event loop cycle, without any delay.
Use Cases:
- ➢Performing asynchronous operations that need to run immediately after the current event loop cycle.
- ➢Handling I/O events or tasks that need to be executed promptly.
Order of execution setImmediate vs setTimeout
Let's have an example and go from there:
// Log the start time
console.log('Start of execution');
// Schedule setImmediate callback
setImmediate(() => {
console.log('setImmediate callback executed');
});
// Schedule setTimeout callback after 0 milliseconds
setTimeout(() => {
console.log('setTimeout callback executed');
}, 0);
// Log the end time
console.log('End of execution');
When you run this code, you'll notice that the logs may not appear in the order you expect. The result is non-deterministic outside of an I/O cycle.
The `setTimeout` with a delay of 0 milliseconds
doesn't guarantee immediate execution. Instead, it queues the callback to be executed in a future iteration of the event loop, after other synchronous tasks like logging the end time.
On the other hand, `setImmediate` ensures that its callback is executed in the next iteration of the event loop, before any timers.
Now let's try that within an I/O cycle:
const fs = require('node:fs');
fs.readFile('path/to/file', () => {
// Log the start time
console.log('Start of execution');
// Schedule setImmediate callback
setImmediate(() => {
console.log('setImmediate callback executed');
});
// Schedule setTimeout callback after 0 milliseconds
setTimeout(() => {
console.log('setTimeout callback executed');
}, 0);
// Log the end time
console.log('End of execution');
});
Now if you run the code, the immediate callback is always executed first!
Understanding process.nextTick()
You might have noticed that process.nextTick()
wasn't part of the event loop diagram. This is because process.nextTick()
operates outside the event loop's phases.
When you call process.nextTick()
, the provided callback is not executed immediately; instead, it's queued to be executed after the current operation completes, regardless of the current phase of the event loop.
An operation, in this context, refers to the transition from the underlying C/C++ handler to handling the corresponding JavaScript code that needs to be executed.
Why? Well the answer in as straight-forward as the question. the reason is that, asynchrony is at the heart of node's Philosophy. So with it, we can easily make any operation asynchronous.
Let's illustrate that with an example:
let bar;
// NB: this has an asynchronous signature, but calls callback synchronously
function asyncApi(callback) {
callback();
}
// the callback is called before `asyncApi` completes.
asyncApi(() => {
console.log('bar', bar); // undefined
});
bar = 1;
As you may have noticed, the `asyncApi` has a async signature but actually is called synchronously, so its callback is executed before rest of the code proceeds, therefore bar is unassigned by the time it's referenced by the callback so the result is `undefined`.
Now let's see how to solve this with process.nextTick
let bar;
// NB: this has an asynchronous signature, but calls callback synchronously
function asyncApi(callback) {
process.nextTick(callback);
}
// the callback is called before `asyncApi` completes.
asyncApi(() => {
console.log('bar', bar); // undefined
});
bar = 1;
By passing the callbac to process.nextTick
, we made sure that :
- It is queued up for execution after all the remaining JS code in stack is completed.
- It is executed before the event loop can proceed
Try using any other timer like `setTimeout` or `setImmediate` and you will get the same result.
So again why process.nextTick?.
There are two main reasons:
- 1.Allow users to handle errors, cleanup any then unneeded resources, or perhaps do some retries before the event loop continues.
- 2.At times it's necessary to allow a callback to run after the call stack has unwound but before the event loop continues.
Conclusion
To conclude, understanding the nodeJS event loop is fundamental for us developers aiming to build efficient and scalable applications. In this series part, we've explored the event loop's key concepts, including its phases and how they interact to handle asynchronous operations.
In the next part, we will explore the nodejs event emitter.
Chapter 1 , Part 1 : Introduction to NodeJS
In this series part, I introduce nodeJS and some technical concepts associated with it. I also show how easy it is to setup and start a simple nodeJS web server.
Chapter 1 , Part 2 : How to Install and Setup NodeJS
In this series part, I run you through the various ways to install nodeJS. I also discuss how to install nvm and use it to switch between different node versions.
Chapter 1 , Part 3 : How much JavaScript do you need to learn NodeJS
In this series part, we explore the nuanced relationship between JavaScript and NodeJS, highlighting some subtle distinctions between the two environments.
Chapter 1 , Part 4 : The v8 Engine and the difference Between NodeJS and the browser
In this series part, we explore the V8 engine and how it interacts with nodeJS. We also discuss node’s event loop and uncover the mystery behinds node’s ability to handle concurrent operations.
Chapter 1 , Part 5 : NPM, the NodeJS package manager
Discover the essentials of npm, the powerful package manager for Node.js. Learn installation, management, publishing, and best practices
Chapter 1 , Part 6 : NodeJS in Development Vs Production
Explore how Node.js behaves differently in development and production environments. Learn key considerations for deploying Node.js applications effectively.
Chapter 2 , Part 1 : Asynchronous Flow Control
In this series part, we'll explore various aspects of asynchronous flow control in Node.js, from basic concepts to advanced techniques.
Chapter 2 , Part 2 : Blocking vs Non-blocking I/O
Explore the differences between blocking and non-blocking I/O in Node.js, and learn how to optimize performance and scalability.
Chapter 2 , Part 3 : Understanding NodeJS Event loop
Exploring the Node.js event loop by understanding its phases, kernel integration, and processes enabling seamless handling of asynchronous operations in your applications.
Chapter 2 , Part 4 : The NodeJS EventEmitter
Explore the power of Node.js EventEmitter: an essential tool for building scalable and event-driven applications. Learn how to utilize it effectively!
Chapter 3 , Part 1 : Working with files in NodeJS
Gain comprehensive insights into file management in Node.js, covering file stats, paths, and descriptors, to streamline and enhance file operations in your applications.
Chapter 3 , Part 2 : Reading and Writing Files in NodeJS
Uncover the fundamentals of reading and writing files in nodeJS with comprehensive examples and use cases for some widely used methods.
Chapter 3 , Part 3 : Working with Folders in NodeJS
Unlock the secrets of folder manipulation in Node.js! Explore essential techniques and methods for working with directories efficiently
Chapter 4 , Part 1 : Running NodeJS Scripts
Master the command line interface for executing nodeJS scripts efficiently. Learn common options and best practices for seamless script execution
Chapter 4 , Part 2 : Reading Environment Variables in NodeJS
Learn how to efficiently manage environment variables in nodeJS applications. Explore various methods and best practices for security and portability
Chapter 4 , Part 3 : Writing Outputs to the Command Line in NodeJS
Learn essential techniques for writing outputs in nodeJS CLI. From basic logging to formatting and understanding stdout/stderr.
Chapter 4 , Part 4 : Reading Inputs from the Command Line in NodeJS
Learn the various ways and strategies to efficiently read command line inputs in nodeJS, making your program more interactive and flexible.
Chapter 4 , Part 5 : The NodeJS Read, Evaluate, Print, and Loop (REPL)
Explore the power of nodeJS's Read, Evaluate, Print, and Loop (REPL). Learn how to use this interactive environment for rapid prototyping, debugging, and experimentation.
Chapter 5 , Part 1 : Introduction to Testing in NodeJS
Discover the fundamentals of testing in nodeJS! Learn about testing types, frameworks, and best practices for building reliable applications.
Chapter 5 , Part 2 : Debugging Tools and Techniques in NodeJS
Explore essential debugging tools and techniques in Node.js development. From built-in options to advanced strategies, and best practices for effective debugging.
Chapter 6 , Part 1 : Project Planning and Setup
Discuss the planning and design process for building our interactive file explorer in Node.js, focusing on core features, UI/UX design, and implementation approach and initial setup.
Chapter 6 , Part 2 : Implementing Basic functionalities
In this guide, we'll implement the basic functionalities of our app which will cover initial welcome and action prompts.
Chapter 6 , Part 3 : Implementating Core Features and Conclusion
In this guide, we'll complete the rest of the more advanced functionalities of our app including, create, search, sort, delete, rename and navigate file directories.