DEV Community

Cover image for UNDERSTANDING THE ASYNCHRONOUS NATURE OF NODE.JS
Okure U. Edet Kingsley🧑‍💻🚀
Okure U. Edet Kingsley🧑‍💻🚀

Posted on

UNDERSTANDING THE ASYNCHRONOUS NATURE OF NODE.JS

Prerequisite

This article provides a clear understanding of asynchronous code in node.js. The reader should have a basic knowledge of JavaScript.

Table of Content

  • Introduction
  • Brief overview of node.js
  • What is asynchronous programming?
  • Blocking and non-blocking code in node.js
  • Event loop in node.js
  • Thread pool
  • Conclusion

Introduction

Node.js is a very powerful runtime environment that can be used to build performant and scalable web applications. Though it is single-threaded, it can perform asynchronous operations with the use of asynchronous code. Node.js then executes such code using the event loop which is at the heart of asynchronous programming in node.js. By the end of this article, you will understand what asynchronous programming means in node.js, blocking and non-blocking code, event loop and thread pool. Let us begin!

Brief overview of node.js

JavaScript code can be executed outside the browser using node.js. But what is node.js? Node.js is an asynchronous, event-driven JavaScript runtime environment. It is built on Google's V8 JavaScript engine. The V8 engine is open source. Using JavaScript outside the browser with the aid of node.js allows JavaScript to do things it could not previously do such as better networking capabilities or accessing a file system. With the power of node.js, JavaScript can now be used on the server side of web development to build performant and highly scalable network applications. One characteristic of node.js is that it is single-threaded and based on an event-driven, non-blocking model. This model ensures that node.js is efficient for building scalable web applications. Now you may be pondering what it means for node.js to be single-threaded. Well, a single-threaded process is when a sequence of code is executed one after another.
For example:

// index.js
const greeting = "Hello";
const firstName = "Kingsley";

const sentence = `${greeting}, my name is ${firstName}`;
console.log(sentence); 
Enter fullscreen mode Exit fullscreen mode
$ node index.js
Hello, my name is Kingsley
Enter fullscreen mode Exit fullscreen mode

In a single-threaded environment, the code above will be executed line by line. It is immaterial if one line of the code takes a very long time to be executed. If this is the case, then this line will block the rest of the code. This can cause an application to slow down especially when there are many users. Thankfully, in node.js, there is a solution to this problem. It is called the event loop. The event loop allows node.js to perform asynchronous, non-blocking I/O operations. It does this by moving aside operations that require a longer time to execute. This article will examine asynchronous I/O non-blocking code and how node.js implements it.

What is asynchronous programming?

To examine how node.js implements asynchronous code, it is essential to understand what asynchronous programming is and why developers may sometimes need it.
Asynchronous programming according to the MDN Docs(https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Introducing) is defined "as a technique that enables your program to start a potentially long-running task and still be able to be responsive to other events while that task runs, rather than having to wait until that task has finished. Once that task has finished, your program is presented with the result". This means that a process on your system is running in parallel with other processes. One of the benefits of asynchronous programming is that it allows various tasks to be handled almost simultaneously thereby ensuring a faster application. In node.js, asynchronous I/O non-blocking code is used to perform more or less heavy tasks. I/O means input and output. Asynchronous code is said to be non-blocking code while synchronous code is said to be blocking code. Let's examine what blocking and non-blocking code mean in the context of asynchronous programming in node.js.

Blocking and non-blocking code

Node.js has synchronous blocking code and asynchronous non-blocking code. Synchronous code is described as blocking code since each code is executed line by line. This then means that if one line of code takes a longer time to process, all other code will be 'blocked' from execution.

Take a look at the synchronous code below:

// index.js

console.log("------------Synchronous code-----------");
const textIn = fs.readFileSync("./txt/input.txt", "utf-8");
const textOut = `This is what we know about node.js: ${textIn}.\nCreated on ${Date.now()}`;
fs.writeFileSync("./txt/output.txt", textOut);
console.log(textOut);
console.log("File written");
Enter fullscreen mode Exit fullscreen mode

Output:

$ node index.js
------------Synchronous code-----------
This is what we know about node.js: Node.js is an asynchronous, event-driven JavaScript runtime designed to build scalable network applications.
Created on 1691915393994
File written
Enter fullscreen mode Exit fullscreen mode

The code above will be executed line by line. It reads a file synchronously and logs it to the console. Now, this may be okay when dealing with a small application that in turn has a small number of users. However, it becomes a problem when dealing with a large number of users who want to access your application all at the same time. The solution to this problem is to always use non-blocking code. Asynchronous non-blocking code is a very essential part of the node.js architecture. It enables us to perform heavy operations without blocking the rest of the code.

For example:
reading a file using asynchronous non-blocking code.

// index.js

fs.readFile("./txt/input.txt", "utf-8", function (err, fileContent) {
  if (err) {
    console.log("ERROR!");
  } else {
    console.log(fileContent);
  }
});

console.log("----------Asynchronous code-----------");
Enter fullscreen mode Exit fullscreen mode
$ node index.js
----------Asynchronous code-----------
Node.js is an asynchronous, event-driven JavaScript runtime designed to build scalable network applications.
Enter fullscreen mode Exit fullscreen mode

If you take a look at the above code, you may have guessed the order in which the code will be executed. Node.js first starts reading the file in the background and then moves on to the next line of code. It then prints ----------Asynchronous code-----------
to the console. After the file has been read, it will then execute the callback function and log the file to the console. Unlike synchronous blocking code, asynchronous non-blocking code like the one shown above does not block other codes in the program from executing. It relegates the line of code that may take time to process to the background.
Thus node.js eliminates the blocking of code through the use of asynchronous non-blocking I/O model.

Event loop in node.js

The event loop is the core innovation of node.js. The execution of asynchronous I/O operation is made possible by the event loop. The event loop runs in the single thread provided by node.js. This means that blocking the event loop will result in the blocking of the entire thread. The event loop automatically commences when a process begins and ends when there is no longer any callbacks to be processed. The event loop delegates intensive operations to the thread pool which is part of the libuv library. It manages these operations and notifies the event loop when the operations are ready to be executed. More on thread pool later.
According to the node documentation, the event loop has six phases: timers, pending I/O call backs, waiting/idle phase, I/O polling, setImmediate() callbacks and close callbacks. These six phases create one loop usually referred to as a tick.
These phases will be examined below.

  • Timers

Timers in node.js are used to schedule the execution of a block of code after a particular time, usually in milliseconds has passed. JavaScript provides two main asynchronous timers: setTimeout() and setInterval(). These functions are contained in a timers module provided to node.js. These timers take in a callback and delay the execution of the callback until the passing of a specified time in milliseconds. Both of these global functions are executed by the event loop. At the beginning of the timer phase, the event loop updates its time and checks a queue of timers. The event loop will select the timer with the shortest delay time and then compare it with the event loop's time. If the wait time has elapsed, it will execute the callback once the call stack is empty. It is important to note that timers may execute later than the time specified. This may be because the call stack is not emptied for the timer in question to be called. Also, a while loop may obstruct the execution of a timer.
For example:

// index.js

let time = false;
setTimeout(function () {
  console.log("Timer 1");
}, 1000);

while (time === false) {
  console.log("waiting for a second...");
}
Enter fullscreen mode Exit fullscreen mode
$ node index.js
waiting for a second...
waiting for a second...
waiting for a second...
Enter fullscreen mode Exit fullscreen mode

In the above example, the while loop blocks the event loop from running. This is possible because node.js is single threaded and blocking the event loop is synonymous to blocking all other code from execution.
As you have seen earlier, there are two types of timer provided by the timer module: setTimeout() and setInterval(). The difference is that the setInterval() is used to execute a function periodically or after a particular interval. In this case, the setInterval() is placed back into the queue to be executed again and again.
For example:

// index.js

setInterval(function () {
  console.log("Interval 1");
}, 1000);
Enter fullscreen mode Exit fullscreen mode
$ node index.js
Interval 1
Interval 1
Interval 1
Enter fullscreen mode Exit fullscreen mode

In the above example, the call back function is executed every second. Thus, this timer schedules the repeated execution of call back after a specified millisecond.

  • I/O call backs

This is the pending call back phase. In this phase of the event loop, the asynchronous non-blocking I/O model becomes crucial.
Here's an example:

// index.js

const fs = require("fs"); // file system

fs.readFile("./txt/input.txt", "utf-8", function (err, fileContent) {
  if (err) {
    console.log(err);
  } else {
    console.log(`Here is the file content: ${fileContent}`);
  }
});

console.log("Asynchronous non-blocking I/O model");
console.log("");
Enter fullscreen mode Exit fullscreen mode
$ node index.js
Asynchronous non-blocking I/O model

Here is the file content: Node.js is an asynchronous, event-driven JavaScript runtime designed to build scalable network applications.
Enter fullscreen mode Exit fullscreen mode

The fs.readFile() is an asynchronous code. While node.js attempts to read a file from the OS, this operation does not block the execution of other code in the application.

  • Waiting/Idle phase

In this phase, the event loop performs the internal operations of the call backs. This phase is used for gathering information on what needs to be executed during the next tick of the event loop. No code needs to be executed during this phase.

  • I/O polling phase

According to the node documentation(https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick), the poll phase has two main function: "calculating how long it should block and poll for I/O and processing events in the poll queue". In this phase, the event loop manages the I/O work load and executes the call backs synchronously after iterating through the queue of callbacks. If the poll queue is empty, it will move on to the check phase to execute the setImmediate() function. If no scripts have been scheduled by the setImmediate() function, the event loop will wait for callbacks to be added to the poll queue and then execute them immediately. If the event loop is empty, it will check for timers that have not been executed and if there are, it will double back to the timer phase to execute the timer callbacks.

  • setImmediate()/check phase

The setImmediate() is usually executed after the poll phase. It runs as soon as the poll queue is empty. If the setImmediate() is within an I/O cycle, it will be executed before any timers.

// index.js

const fs = require("fs"); // file system

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log("Timer 1");
  }, 0);

  setTimeout(function () {
    console.log("Timer 2");
  }, 100);

  setImmediate(() => {
    console.log("immediate");
  });
});
Enter fullscreen mode Exit fullscreen mode
$ node index.js
immediate
Timer 1
Timer 2
Enter fullscreen mode Exit fullscreen mode

The setImmediate() and the timer functions are within an I/O callback and so the setImmediate() will always be executed before the setTimeout().

  • Close callbacks

In this phase, the callbacks of all close events are executed. For example the process.exit() method. Or where a socket or handle is closed i.e socket.destroy().

To recap, when a node process begins to run, the event loop kicks off. It starts with the timer phase and executes the timers with the least delay time, then it moves on to I/O callbacks, then it does some internal processing, after this phase, it then moves on to execute callbacks on the poll queue. This is done synchronously. The event loop then executes setImediate() and finally it executes close callbacks. It repeats this loop/tick until there are no more callbacks to execute. At this point, it is essential to note that there are some tasks that are too heavy to be executed by the event loop. And that is where the thread pool comes into play.

Thread pool

The thread pool is another fundamental part of the node.js architecture. The libuv library provides us with the thread pool. The thread pool provides four additional threads that are completely separate from the main single thread. The thread pool can be configured up to one hundred and twenty eight threads. However, most of the time, these four threads are enough. When the event loop encounters heavy tasks, these tasks are then automatically delegated to the thread pool. Tasks like operations dealing with files, compression related functions, DNS lookups and so on are usually delegated to the thread pool. The reason these tasks are delegated to the thread pool is so as to avoid prevent them from blocking the event loop.

Conclusion

In summary, the asynchronous I/O model is a fundamental part of how node.js handles relatively heavy tasks. This article has laid down the basic idea of how asynchronous programming works in node.js using the event loop and the thread pool. Both of these are provided for by the libuv library which is written in C++. This whole architecture helps you as a developer to build performant and scalable web applications.

Top comments (0)