Event Loop in NodeJS
Deep dive in Nodejs Internals (Blocking, Non-blocking IO, event loop, nextTick, promises)
In this article, you can expect Event Loop explained in depth, so if you are a beginner and you still can understand this, you rock. As a Sr. engineer myself writing this article helped me a lot to clear out any misconception I had all these years as when you begin to learn JavaScript event loop is very abstract and with those concepts when moving to Nodejs it can be easy to follow these misconceptions. Plus there are lots of wrong diagrams present on the internet.
This article is the third article of my Advanced NodeJS for Senior Engineers Series. In this article, I’m going to explain what, why, and how they work in detail, and how Event Loop of NodeJS. You can find the other articles of the Advanced NodeJS for Senior Engineers series below:
Post Series Roadmap
Event loop in Nodejs
An event loop is often referred to as a “semi-infinite loop” as it runs till there are no events to handle i.e. if the loop is alive. If there are any active handles or if there are any active requests.
Handles: These represent long-lived objects such as timers, signals, and TCP/UDP sockets. Once a task is completed, handles will trigger the appropriate callbacks. The event loop will keep running as long as a handle remains active.
Requests: Represent short-lived operations, such as reading from or writing to a file or establishing a network connection. Like handles, active requests will keep the event loop alive.
Min heap is a data structure that guarantees fast & and easy access to minimum value in the heap. So if the nearest expiring timer would be more easily accessible.
Interestingly one node timer is not equal to one libuv timer as it would load up the garbage collector. So if there are two or multiple timers due at the same time they are combined and backed by the single libuv timers. so the below example with 2 node timers would have a single libuv.
setTimeout(() => {}, 50);
setTimeout(() => {}, 50);
So these steps can be narrowed down to phases or queues if you think so, each box will be referred to as a “phase” of the event loop.
process.nextTick and promise callbacks
Now this was about Macrotasks what about Microtasks such as process.nextTick()and promise callbacks? Noticed that process.nextTick() was not displayed in the diagram because process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
process.nextTick() is a function that allows a callback function to be executed immediately after the current operation completes, but before the Event Loop proceeds to the next phase. This can create some bad situations because it allows you to “starve” your I/O by making recursive process.nextTick() calls, which prevents the event loop from reaching the poll phase.
When to use process.nextTick():
In terms of output sequencing, the process.nextTick() callbacks will always be executed before the Promise callbacks.
The conceptual diagram would look somewhat like this
I/O Polling
Let’s take a look at some examples.
Example 1)
const fs = require('fs')
setTimeout(() => {
console.log('hello');
}, 0);
fs.readFile('./AWS Migration.txt', () => {
console.log('world');
});
setImmediate(() => {
console.log('immediate');
});
for (let index = 0; index > 2000000000; index++) {}
hello
immediate
world
You would have expected world to be printed first, right? Let take a look step by step.
Example 2)
const fs = require('fs')
const now = Date.now();setTimeout(() => {
console.log('hello');
}, 50);fs.readFile(__filename, () => {
console.log('world');
});setImmediate(() => {
console.log('immediate');
});while(Date.now() - now < 2000) {} // 2 second block
We have three operations: setTimeot, readFile, and setImmediate. After that, there is a while loop that blocks the thread for two seconds. Within this time, all three events should be added to their respective queues. So, when the while loop is done, EventLoop will process all three events in the same cycle and execute the callbacks in the order shown in the diagram.
hello
world
immediate
But the actual result looks like this:
hello
immediate
world
It’s because there is an extra process called I/O Polling.
I/O events are added to their queue at a specific point in the cycle, unlike other types of events. That’s why the callback for setImmediate() will run before the callback for readFile(), even though both are ready when the while loop is finished.
The problem is that the EventLoop’s I/O queue-checking stage only executes callbacks that are already in the event queue. They are not automatically added to the event queue when they are finished. Instead, they are added to the event queue later during the I/O polling stage.
Here is what happens after two seconds when the while loop is finished.
This example can be a bit challenging to understand, but it provides valuable insight into the I/O polling process. If you were to remove the two-second while loop, you would notice a different result.
immediate
world
hello
setImmediate() will work in the first cycle of EventLoop when neither of the setTimeout or File Systems processes is finished. After a certain period, the timeout will finish and the EventLoop will execute the corresponding callback. At a later point, when the file has been read, the EventLoop will execute the readFile’s callback.
Everything depends on the delay of the timeouts and the size of the file. If the file is large, it will take longer for the read process to complete. Similarly, if the timeout delay is long, the file read process may complete before the timeout. However, the setImmediate() callback is fixed and will always be registered in the event queue as soon as V8 executes it.
Hands On Examples
Example 1) setTimeout
console.log('first');
setTimeout(() => { console.log('second') }, 10);
console.log('third');
first
third
second
This is quite straightforward forward as if we visualize 1st and 3rd line code are synchronous user code so that is run and time had 10 milisec so it ran later.
Example 2) setTimeout of 0
console.log('first');
setTimeout(() => { console.log('second') }, 0);
console.log('third');
first
third
second
But why this gave a similar output? Yeah you got it right cause even though its 0 milisec its an async function and it will still be pushed into timer queue and then executed. So that takes time.
Example 3) setTimeout is 0 but the other call is blocking
What if the third call blocks the loop for 3 seconds will the second be called as we give it as 0 milisecs?
console.log('first');
setTimeout(() => { console.log('second') }, 0);
const startTime = new Date()
const endTime = new Date(startTime.getTime() + 3000)
while (new Date() < endTime) {
}
console.log('third');
first
third
second
second is printed still after the third as even though it is mentioned 0 milisec timeout, it is not guaranteed 0 sec as the user code will take precedence. If the user sync code is blocking the event loop the the timers are going to starve that's why its said not to block the event loop.
Example 4) setTimeout & setImmediate
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
Sometimes a process can take longer(this is about milliseconds) to execute, causing the EventLoop to move past the timers queue when it is empty. Alternatively, the EventLoop may work too quickly, causing the demultiplexer to not manage to register the event in the Event Queue in time. As a result, if you run this example multiple times, you may get different results each time.
Example 4) setTimeout & setImmediate inside fs callback
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
setImmediate
setTimeout
As the setTimeout and setImmediate are written inside the readFile function, we know that when the callback will be executed, then the EventLoop is in the I/O phase. So, the next one in its direction is the setImmediate queue. And as the setImmediate is an immediately get registered in the queue, it's not surprising that the logs will always be in this order.
Example 5) process.nextTick & Promise
console.log('first');
process.nextTick(() => {
console.log('nextTick');
});
Promise.resolve()
.then(() => {
console.log('Promise');
});
console.log('second');
first
second
nextTick
Promise
Example 5) process.nextTick nesting
process.nextTick(() => {
console.log('nextTick 1');
process.nextTick(() => {
console.log('nextTick 2');
process.nextTick(() => console.log('nextTick 3'));
process.nextTick(() => console.log('nextTick 4'));
});
process.nextTick(() => {
console.log('nextTick 5');
process.nextTick(() => console.log('nextTick 6'));
process.nextTick(() => console.log('nextTick 7'));
});
});
nextTick 1
nextTick 2
nextTick 5
nextTick 3
nextTick 4
nextTick 6
nextTick 7
Here is the explanation: When this code is executed, it schedules a series of nested process.nextTick callbacks.
Here is an overview of how the queue will be structured throughout the execution.
Proess started: [ nT1 ]
nT1 executed: [ nT2, nT5 ]
nT2 executed: [ nT5, nT3, nT4 ]
nT5 executed: [ nT3, nT4, nT6, nT7 ]
// ...
Example 6) process.nextTick promises and setTimeouts
process.nextTick(() => {
console.log('nextTick');
});
Promise.resolve()
.then(() => {
console.log('Promise');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
nextTick
Promise
setTimeout
setImmediate
Example 7) IO, process.nextTick promises and setTimeouts setImmediate
const fs = require('fs');
fs.readFile(__filename, () => {
process.nextTick(() => {
console.log('nextTick in fs');
});
setTimeout(() => {
console.log('setTimeout');
process.nextTick(() => {
console.log('nextTick in setTimeout');
});
}, 0);
setImmediate(() => {
console.log('setImmediate');
process.nextTick(() => {
console.log('nextTick in setImmediate');
Promise.resolve()
.then(() => {
console.log('Promise in setImmediate');
});
});
});
});
nextTick in fs
setImmediate
nextTick in setImmediate
Promise in setImmediate
setTimeout
nextTick in setTimeout
When V8 executes the code, initially there is only one operation, which is fs.readFile(). While this operation is being processed, the Event Loop starts its work by checking each queue. It continues checking the queues until the counter (I hope you remember it) reaches 0, at which point the Event Loop will exit the process.
Eventually, the file system read operation will be completed, and the Event Loop will detect it while checking the I/O queue. Inside the callback function there are three new operations: nextTick, setTimeout, and setImmediate.
Now, think about the priorities.
After each Macrotask queue, our Microtasks are executed. This means “nextTick in fs” will be logged. As the Microtask queues are empty EventLoop goes forward. And in the next phase is the immediate queue. So “setImmediate” will be logged. In addition, it also registers an event in the nextTick queue.
Now, when no immediate events are remaining, JavaScript begins to check the Microtask queues. Consequently, “nextTick in setImmediate” will be logged, and simultaneously, an event will be added to the Promise queue. Since the nextTick queue is now empty, JavaScript proceeds to check the Promise queue, where the newly registered event triggers the logging of “Promise in setImmediate”.
At this stage, all Microtask queues are empty, so the Event Loop proceeds, and next, where it finds an event inside the timers queue. Now, at the end “setTimeout” and “nextTick in setTimeout” will be logged with the same logic as we discussed.
Example 8) IO, process.nextTick promises and setTimeouts setImmediate
setTimeout(() => console.log('Timeout 1'));
setTimeout(() => {
console.log('Timeout 2');
Promise.resolve().then(() => console.log('promise resolve'));
});
setTimeout(() => console.log('Timeout 3'));
Timeout 1
Timeout 2
promise resolve
Timeout 3
References