Understanding Nodejs Event Loop

It took me a while to understand how NodeJS achieves non-blocking IO within one single thread. The misconception about NodeJS being single-threaded is what causes my confusion. In this post, I'll demonstrate that NodeJS is not completely single-threaded, and show you how event loops work.

A quick glimpse of non-blocking I/O #

Consider this piece of code:

const crypto = require('crypto');

const start = Date.now();

crypto.pbkdf2('secret', 'salt', 100000, 512, 'sha512', () => {
console.log('1:', Date.now() - start);
}); //1: 1015

crypto.pbkdf2('secret', 'salt', 100000, 512, 'sha512', () => {
console.log('2:', Date.now() - start);
}); //2: 1021

crypto.pbkdf2('secret', 'salt', 100000, 512, 'sha512', () => {
console.log('3:', Date.now() - start);
}); //3: 1017

When the above code gets executed, all the function calls enter into the event loop. After running the code, you'll find that these 3 function calls take almost the same amount of time. Remember that the event loop is in one single thread? How in the world that Node manages to run 3 operations in parallel within one single thread?

Node is built upon C/C++ #

The behavior we observed earlier was because Node implements a C module called libuv. Whenever a long-running operation takes place in the event loop, libuv will come to help and put that task into another thread. When the operation finishes, the event loop will trigger the callback to handle the result.

Now, increase the crypto.pbkdf2() function call to 5 times, and see what happens.

You will find that the first 4 calls take almost the same amount of time, while the last takes double. Here we encounter an interesting part of Node. By default, the libuv library initiates 4 threads in something called "thread pool". The 4 threads run in parallel, that's why the 4 operations take the same amount of time. Because the thread pool can only take 4 operations in a time, the fifth operation just waits for previous operations to finish. That's why the fifth operation takes longer.

Now, let's change the type of operations. This time we will make http calls in parallel and see what happens.

const https = require('https');

const start = Date.now();

function makeRequest() {
https
.request('https://www.google.com', res => {
res.on('data', () => {});
res.on('end', () => {
console.log(Date.now() - start);
});
})
.end();
}

makeRequest(); //67
makeRequest(); //72
makeRequest(); //72
makeRequest(); //73
makeRequest(); //74

You'll find that these 5 calls take almost the same amount of time. We can reasonably infer that these http calls happen neither in the single-threaded event loop nor in the thread pool. So, what happened?

It turns out that some low-level OS tasks like http requests are delegated to the operating system by libuv. These tasks get executed in parallel outside of event loop and the thread pool.

The event loop also handles timer events, i.e. setTimeout, setInterval, and setImmediate. When a timer event is registered, the event loop will wait the specified amount of time and trigger the callback.

In summary, the event loop keeps track of these three kinds of events:

  1. Timer events, as listed above.
  2. OS tasks, such as server listening and http requests.
  3. Long-running operations, such as fs operations and other NodeJS API.

Here is a diagram I drew to illustrate the big picture:

Event Loop Diagram

🙏🙏🙏

Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please ping me on Twitter.

Published