Explore the I/O Callbacks Phase of the Node.js Event Loop

When writing custom Node.js applications, the biggest benefit you get from Node's Event Loop is non-blocking Input/Output (I/O). It's one of the features of Node that has made it a popular framework. Having a full understanding of how the Node.js runtime accommodates non-blocking code will help you write better, and faster, applications. In Node, I/O operations are offloaded to the C++ APIs (libUV) which allows the Node.js main thread to continue executing your code without having to wait for file or network operations to finish.

In this tutorial we will:

  • Learn how to write synchronous and asynchronous file callbacks in Node
  • Explore the second phase of the Event Loop, I/O callbacks phase, through code examples
  • Understand the difference between blocking and non-blocking code

By the end of this tutorial, you'll understand how Node's Event Loop deals with blocking and non-blocking code, and as a result write more efficient applications.

Goal

Use the asynchronous features of Node's fs module to learn about the second phase of Node's Event Loop, the I/O callbacks phase, as well as to gain a solid understanding of blocking vs. non-blocking code.

Prerequisites

Working with I/O in a synchronous way

In Node.js, I/O often refers to reading/writing files or network operations. Network operations get external information into your application, or send data from your application out to something else. In our examples we will be focusing on the file system, but you could also explore these examples using network operations.

In Node.js, file system operations are provided by the fs core module. It contains helper functions for working with files and directories. Many of the module's functions have both synchronous and asynchronous versions. This feature allows us to use its functions in either a blocking or non-blocking way.

Let's focus on the synchronous version first. To understand it better, imagine you're at a fast food restaurant. You go up to the counter, order your food, pay the bill, and then wait there for the food to be prepared before you can go to your table and start eating. This is an example of a synchronous request. You requested the food and are standing in an idle/waiting state while it's being prepared. This also illustrates blocking code. You are blocked from doing anything else like going to a table or exploring the restaurant, and have to stay close to the counter waiting for your number to be called.

Let's transfer this into code. Create the file io.js with the following code:

// Requiring fs module and getting its reference.
const fs = require('fs');

console.log('I am about to read the file');

const start = Date.now()

// Reading the file synchronously.
const text = fs.readFileSync('README.txt', 'utf-8');

const end = Date.now()
console.log(`Reading the file blocked for ${end - start} milliseconds`)
console.log('All done!'); 

We also need a file to read from. Let's create one named README.txt in the same directory as our io.js file and add some text to it.

Run this command to create the file:

$ echo "Hello World!!" > README.txt

Run the example:

$ node io.js

You should see output like the following:

$ node io.js
I am about to read the file
Hello World!!
All done!

Our code reads the file and outputs its contents. How does this relate to the Event Loop? When our application starts, Node moves past the timers phase, since there are no timers. It skips the I/O callbacks phase, since there are no I/O callbacks. Same for the idle phase. Then it's into the polling phase. In the polling phase, Node requires the fs module and executes the console.log() statement. Then it starts reading the README.txt file via fs.readFileSync(). Note that we didn't specify any callbacks or error handlers. Since this is a synchronous function, the main thread dives right into reading the file, and will not move on to the next console.log() statement until the read operation is finished.

You can see the delay this causes if you read a bigger file. Add more text into your README.txt. Alternatively, you can use this command to download an example big file:

$ curl https://norvig.com/big.txt -o README.txt

The file is now a few MBs big. You can remove printing of the file's content to the screen, as we don't need to overload the terminal with text. Then run the script again. Did you notice the slight delay between the first and second console messages? This indicates that the code execution is blocked, since the application is working hard to read the content of a file.

Working with I/O in an asynchronous way

Let's go back to our fast food restaurant example. During the previous visit you were stuck at the counter waiting for your food. But now, the restaurant has a new table service option. Now you can order food, take a number, and proceed to your table. Enjoy being out together with the family while waiting for your food. At the same time, your food is being cooked, and will be delivered to you when it's complete. That's a much better experience, right?

Node.js provides its equivalent of a "table service" for I/O via asynchronous operations. Let's modify our code from the previous example to read the large file asynchronously.

Edit io.js to look like this:

// Requiring fs module and getting its reference.
const fs = require('fs');
console.log('I am about to read the file');

// Reading the file asynchronously.
fs.readFile('README.txt', (err, data) => {
  if (err) {
    console.log(err);
  } else {
    console.log('Success');
  }
});

console.log('All done!');

Then run the file and observe the results:

$ node io.js
I am about to read the file
All done!
Success

The biggest difference that you'll notice immediately is that the "All done!" message is printed before the "Success" one. Let's see what is going on here in terms of the Event Loop.

Like before, Node skips ahead to the "polling" phase when the process first starts. There Node calls console.log() and prints "I am about to read the file". Then Node hits the asynchronous read file function fs.readFile(). Here, instead of blocking the thread like before, this time Node offloads the I/O operation to the asynchronous C++ APIs. It provides a callback function as the second argument to fs.readFile() to execute when the read operation is completed.

At the same time, this callback gets added to the I/O callbacks queue. The code execution proceeds past the readFile() call, and into the last console.log(), which prints "All done!". The Event Loop continues, because there are unfinished operations in the callback queue. When the file reading operation is done, and the Event Loop is next in the I/O callbacks phase, Node calls the callback function passed to fs.readFile(), and prints "Success".

Like the restaurant example, the food was ready and the server brought it to us while we were already sitting at the table and enjoying ourselves.

Recap

Node.js allows for two different approaches when it comes to I/O operations: synchronous, or blocking operations; and asynchronous, or non-blocking ones. We looked at example code using the fs module, which can be either synchronous or asynchronous, as a way to explore what happens in the Event Loop in order to allow for a single threaded application to handle non-blocking asynchronous I/O.

We learned that the asynchronous callbacks of I/O operations are processed during the I/O callbacks phase of the Event Loop. By utilizing these asynchronous capabilities of Node.js, our application can continue with the main code execution while heavy file and network operations are handled concurrently by libUV.

Further your understanding

  • Try mixing asynchronous and synchronous calls to the same file. What behavior do you observe? What could go wrong when doing this?

Additional resources

Sign in with your Osio Labs account
to gain instant access to our entire library.