Understanding the event loop in NodeJS for beginners

Async Code Execution

  • JavaScript is a synchronous, blocking, and single-threaded language

  • To make async programming possible, we make use of libuv

Code Execution in NodeJS (overview)

On the left, we have the V8 engine that executes the JavaScript Code. It comprises of a Memory Heap and a Call Stack. Whenever we define a variable/function, memory is allocated on the memory heap. Whenever we execute code, functions are pushed to call stack, and when the function returns, it pops off from the stack.

On the right, we have the libuv. Whenever we execute an async method, it is offloaded to libuv. Libuv will then run the task using the native async mechanism of the OS, and if that is not possible, it will utilize its thread pool to run the task, ensuring the main thread is not blocked.

Synchronous Code Execution in NodeJS

Let us consider the code snippet present on the left side of V8 Engine. The main thread of execution always starts from the global scope (let’s call it the global function). The global function is pushed on the stack.

Then, on the first line, we have a console.log() statement, which is pushed on the stack (let’s say at 1ms, to understand the timeline)

Then, “First” is logged on the console and the function is popped off from the stack.

Now, the program would execute the second line of code. It will push the console.log() to the call stack at 2ms (let's say), which will then be executed, and we will have “Second” on the console, and the console.log() would be popped off from the call stack. Similarly, we will have the “Third” on the console. Since there is no more code to execute, global() will also be popped off from the stack. This is how synchronous code executes in the runtime.

Asynchronous Code Execution in NodeJS

On the left, we have a piece of code snippet. Three log statements, but this time, the second log statement, is within a callback function, passed to fs.readFile method. Just like sync functions, the main thread of execution will start from the global() scope, which is pushed on the stack like the following

Execution comes to the first line of the code snippet, at 1 ms (let’s say), console.log() is pushed on the call stack, which is then executed, and “First” is logged on the console, after which, console.log() is popped from the stack. Execution will now move to the next line.

At 2ms, the readFile method is pushed on the stack

readFile is an async method, and it will be offloaded to libuv. So, now, the callback function is handed over to libuv as shown.

JavaScript will now pop off the readFile() from the call stack since its job is done as far as the execution of line 2 is concerned. In the background, libuv will start to read the contents of the file in a separate thread.

At 3 ms, JavaScript will proceed to line number 7 of the snippet, and push the log function to the call stack.

Now, the console.log() will be executed and will be popped off from the stack. Now, there will be no user-written code in the global scope to execute, so the call stack is empty.

Let's say, that at 4 ms, the file read task is complete in the thread pool, so the associated callback function is now pushed to the call stack. In the callback function, we have console.log() which is again pushed in the call stack.

The call stack will execute console.log() and “Second” will be printed on the console and the method will be popped off from the stack. Since there is nothing to execute, callback() will also be popped off from the stack, and then finally, global() will also be popped from the stack. The console output will be finally,

Few Questions [Answers at the end of article]

  1. Whenever an async task completes in libuv, at what point, does nodeJS decide to run the associated callback function on the call stack? Does it wait for the call stack to be empty or does it interrupt the normal flow of execution to run the callback function?

  2. What about async functions like setTimeout and setInterval which also delay the execution of a callback function

  3. If two async tasks such as setTimeout and readFile complete at the same time, how does NodeJS decide which callback function to run first on the call stack?

All these questions can be easily answered after understanding the concept of Event-Loop

Event Loop

It is simply a C program and is a part of libuv.

It is basically a design pattern that orchestrates and coordinates the execution of sync and async code in NodeJS.

To understand it better,

Event Loop is a loop that is continuously running until our nodeJS application is running. In every iteration of the loop, we come across 6 different queues. Each queue holds one or more callback functions that need to be eventually executed on the call stack. Also, the type of callback functions are also different for each queue.

We have the following different queues:

  1. Timer Queue: This contains callbacks related to setTimeout and setInterval.

  2. I/O Queue: This contains callbacks for all the async methods present in fs, http, etc modules.

  3. Check Queue: Check Queue contains callbacks present in setImmediate. This method is specific to NodeJS, and we might not come across this when we are writing JS for the browser.

  4. Close Queue: This contains callbacks associated with the close event of an async task.

  5. Microtask Queue: This contains two separate queues inside it

  6. nextTick Queue: contains callbacks associated with a function called process.nextTick which is specific to NodeJS

  7. Promise Queue: contains callbacks which are associated with native promises in JS

Timer Queue, I/O Queue, Check Queue, and Close Queue are part of libuv, whereas the two queues in microtask queues are not part of libuv. They are all a part of node runtime and decide the order of execution of callbacks.

Event Loop — Execution Order

  • All the user-written synchronous JS code takes priority over the async code that the runtime would like to execute.

  • This means, that only after the call stack is empty, the event loop come into the picture.

  • Within the event loop, we have a few rules to execute the callbacks:

  1. Any callbacks in the microtask queues are executed. First, tasks in the nextTick Queue, and only then the tasks in the Promise Queue.

  2. All callbacks within the timer queue are executed. After executing each callback in the timer queue, Callbacks in the microtask queue (if present) are executed. Again, first in the nextTick queue and then tasks in the promise queue.

  3. All callbacks in the I/O queue are executed and after the execution of every callback in the I/O queue, Callbacks in the microtask queue (if present) are executed. Again, first in the nextTick queue and then tasks in the promise queue.

  4. All callbacks in the check queue are executed and after execution of every callback in the check queue, Callbacks in the microtask queue (if present) are executed. Again, first in the nextTick queue and then tasks in the promise queue.

  5. All callbacks in the close queue are executed and after the execution of every callback in the close queue, Callbacks in the microtask queue (if present) are executed. Again, first in the nextTick queue and then tasks in the promise queue.

If there are more callbacks to be processed, the loop is kept alive for another iteration, and the same steps are followed.
On the other hand, if there are no more callbacks, and there is no more code to process, the event loop exits.

This is the role, libuv’s event loop plays in the execution of async code in NodeJS.

So for the questions in the above section, the answers would be:

  1. Callback functions are executed only when the call stack is empty. The normal flow of execution will not be interrupted to run a callback function.

  2. setTimeout and setInterval functions are given first priority

  3. Timer callbacks are executed before I/O callbacks even if both are ready at the exact same time.

I hope this will give you a clear picture of the event-loop and how it executes callbacks in NodeJS.