Node.js: Event Loop

Node.js: Event Loop

·

7 min read

What is an Event Loop?

By default, JS is designed to handle synchronous tasks only. Event Loop is a process by which Node.js handles asynchronous functions.

Let's see a code example and see which line of code gets printed first:

// Asynchronous Timer Callback
setTimeout(() => console.log("Timer"), 0);
// Synchronous Code
for(let i = 1; i <= 5; i++){ 
    console.log("Iteration ", i);
}

We may expect the timer callback to run first and then the for loop. But, the output of this code is:

Iteration 1
Iteration 2
Iteration 3
Iteration 4
Iteration 5
Timer

Asynchronous codes are allowed to run only after all the synchronous codes are finished executing no matter how much time it takes to complete those synchronous codes. Since the timer callback with 0 delay is asynchronous, it runs after the for-loop.

There are many types of asynchronous functions in Node.js. Let's see another example of that:

const fs  = require("fs");

console.log("Synchronous 1");
// Asynchronous File Read
fs.readFile("./ReadMe.txt", "utf8", (err, res) => {
    console.log("Read File");
});
// Asynchronous Timer Callback
setTimeout(() => console.log("Timer"), 0);

console.log("Synchronous 2");

The output of this code is:

Synchronous 1
Synchronous 2
Timer
Read File

The two console logs get executed first because they are synchronous. Now, we are left with two asynchronous functions which are readFile and timer callback. Even though the readFile was written before the timer callback, the timer callback gets executed before the readFile because the timer callback has higher priority.

Why did Node.js do that? How does it set the priority when there are multiple asynchronous functions?

It does so with the help of Event Loop. When Node.js finishes executing all the synchronous codes, it enters the event loop to decide which asynchronous function to execute. You can see the simplified representation of the event loop below.

💡
Event Loop is all about checking some queues(containing asynchronous functions) one after another and running functions inside those queues.

Let's understand the event loop from the very start:

  • When we run our node.js code, the control goes from top-to-bottom.

  • The synchronous codes get executed immediately when encountered by the control.

  • When the asynchronous functions are encountered, they are stored in their respective queue as shown in the above event loop diagram.

  • After all the synchronous codes are finished, the control goes inside the event loop.

  • There are 2 types of queues in the event loop: MicroQueue and MacroQueue

  • MicroQueue stores smaller tasks and MacroQueue(Timer Queue, I/O Queue, Check Queue, Close Queue) stores macro tasks.

  • The event loop checks MicroQueue every time after checking MacroQueue.

  • The event loop checks the queues one by one and runs the functions stored in it.

  • Again, the process is repeated from the start if more functions need to be executed in the next iteration.

  • If there are no more functions left to be executed, then the control exits the event loop and our program gets terminated.

Event Loop in Depth

What type of functions are there in MicroQueue and MacroQueue? Let's see inside the queues:

Micro Queue

It is the first queue checked by the program control during the event loop. For a mental model, let's break down the micro-task queue into 2 queues: nextTick Queue and Promise Queue where nextTick Queue has higher priority.

The nextTick Queue stores the callback from "process.nextTick(callback)" and the Promise Queue stores the resolved/rejected promise callback. For example:

// Callback going into Next Tick Queue
process.nextTick(() => console.log("Next Tick"));
// Callback going into Promise Queue
Promise.resolve().then(() => console.log("Promise"));
💡
NOTE: We know MicroQueue is checked every time after a MacroQueue is checked. So, we use process.nextTick() to execute some functionality right after any current Macro Task is completed. Hence, named the next tick.

Timer Queue

After the micro-task queue, program control checks the timer queue. The timer functions like setTimeout and setInterval are stored in this queue. The functions inside this queue don't get executed until the delay time is up. For example, during the event loop, if the program control encounters a setTimeout function inside the timer queue, then it also checks the delay. If some delay time is still left, then the control skips it and continues. In the next iteration of the event loop, if the delay time is found to be expired, then that setTimeout function is executed.

I/O Queue

This queue stores callbacks related to the network, file operations and so on. Let's see an example of reading a file asynchronously:

const fs  = require("fs");
// Asynchronous Disk Read callback goes into I/O Queue 
fs.readFile("./ReadMe.txt", "utf8", () => console.log("Read File"));

When program control encounters this readFile, it stores it in the I/O queue and the function gets executed when the operation is completed.

NOTE: The readFile function is not executed immediately during the event loop. The control goes through a process called Polling(checks if the read operation is complete or not), then only the readFile callback is executed.

Check Queue

Function like setImmediate() is stored in this queue. The setImmediate function is similar to setTimeout with 0 delay. The only difference is that it is stored in a separate queue with lower priority. So, the callback from setImmediate gets executed instantly when the control reaches the check queue during the event loop. For example:

// Asynchornous Check Callback
setImmediate(() => console.log("Set Immediate"));
💡
Remember process.nextTick()? The next tick is a micro-task that gets executed instantly after a current macro task. The setImmediate is itself a macro-task that gets executed instantly when its turn comes in the event loop.

Close Queue

It stores the close handlers used for cleaning up. Let's see an example by creating a read stream and its close handler:

const fs  = require("fs");
// Creatign a Read Stream
const reader = fs.createReadStream("./ReadMe.txt");
// Close Callback that gets stored in the Close Queue
reader.on("close", () => console.log("Close Handler")); 
// Trigger Close Event 
reader.close();

Conclusion

The functions stored in the respective queues are executed according to the priority shown in the above event loop diagram. In this way, regardless of the order of the asynchronous functions in the program, they get executed according to their priority. The event loop uses a special reference variable(basically a counter) to determine whether it should exit the loop or not.

Misconception

You may now agree that the asynchronous codes are going to run in the order that is mentioned in the event loop. Let's verify it by putting the synchronous and asynchronous functions in a different order:

 const fs  = require("fs");
const reader = fs.createReadStream("./ReadMe.txt");
reader.on("close", () => console.log("Close Handler"));

// Synchronous Callback
console.log("Synchronous 1");
// Asynchronous I/O Callback
fs.readFile("./ReadMe.txt", "utf8", () => {
    console.log("Read File")
});
// Close Event
reader.close();
// Asynchornous Check Callback
setImmediate(() => console.log("Set Immediate"));
// Asynchronous Timer Callback
setTimeout(() => console.log("Timer"), 0);
// Asynchronous Promise Callback
Promise.resolve().then(() => console.log("Promise"));
// Asynchronous Next Tick Callback
process.nextTick(() => console.log('Next Tick'));
// Synchronous Callback
console.log("Synchronous 2");

The output of this code is:

Synchronous 1
Synchronous 2
Next Tick
Promise
Timer
Set Immediate
Close Handler
Read File
  • The two synchronous functions ran first which is okay.

  • The control entered the event loop and executed the next tick and promise from the Micro-task Queue which is also okay.

  • Then the timer callback is executed from the Timer Queue. That's fine.

  • The setImmediate from the Check Queue is running after the timer callback. Okay, we will allow it since the Check Queue has lower priority anyway.

  • Next, the close handler from Close Queue is executed which is okay because it has lower priority than Check Queue.

  • Finally, readFile is executed at the end. But wait! We know the I/O queue lies above the Check Queue in the event loop. So, isn't the readFile supposed to run before setImmediate? If not, then is the above event loop diagram wrong?

Don't worry. The order of queues in the event loop diagram is correct.

💡
Remember the process of Polling during read operation?
  • When it was the turn of the readFile function in the event loop, the program control went into the Polling process where it verified if the read operation was completed or not at that specific time.

  • Let us assume the operation was completed. Now, Node.js just knows the read operation is completed and it goes to check the next queues without running the readFile callback. So, other functions of the queues run before the readFile.

  • In the next iteration, Node.js sees all the queues empty except the I/O queue with readFile callback in it. Since the Polling had already verified the completion of the read operation, the readFile callback now gets executed at the last.