Event Loop in NodeJS

Deep dive in Nodejs Internals (Blocking, Non-blocking IO, event loop, nextTick, promises)

Manik Mudholkar
13 min readJan 2, 2024

By Manik Mudholkar (Sr. Sw. Development Engineer)

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

* The V8 JavaScript Engine
* Async IO in NodeJS
* Event Loop in NodeJS (This Article)
* Worker Threads : Multitasking in NodeJS
* Child Processes: Multitasking in NodeJS
* Clustering and PM2: Mutlitasking in NodeJS
* Debunking Common NodeJS Misconceptions
Table of Content

* Event loop in Nodejs
* process.nextTick and promise callbacks
* I/O Polling
* Hands On Examples
* setTimeout
* setTimeout of 0
* setTimeout is 0 but other call is blocking
* setTimeout & setImmediate
* setTimeout & setImmediate inside fs callback
* process.nextTick & Promise
* process.nextTick nesting
* process.nextTick promises and setTimeouts
* IO, process.nextTick promises and setTimeouts setImmediate

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 beginning to learn JavaScript event loop is very abstract and with that concepts when move to Nodejs it can be easy follow these misconception. Plus there are lots of wrong diagrams present on the internet.

Event loop in Nodejs

Event loop is often referred to as a “semi-infinite loop” as it runs till there no events to handle i.e. if the loop is alive. If there are any active handles or if there 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 network connection. Like handles, active requests will keep the event loop alive.

https://docs.libuv.org/en/v1.x/design.html
  1. At the beginning of every loop iteration, the event loop calculates the current time (now) and stores it as a reference for the entire iteration. The calculated time is saved (cached) to reduce the frequency of system calls.
  2. If the loop was run with UV_RUN_DEFAULT, timers will be executed. At this point, there is a separate queue of callbacks that are scheduled to run through functions like setTimeout or setInterval.
  3. Check if a loop is alive by checking if there are any referenced handles, active requests or closing handles.
  4. Pending callbacks are called. Most I/O callbacks are called immediately after checking for I/O. However, there are situations where calling a callback is postponed until the next loop iteration. If any I/O callback was deferred in the previous iteration, it will be executed at this stage.
  5. Idle handle callbacks are invoked. Despite the unfortunate name, idle handles are executed on each loop iteration, if they are active. These callbacks are used to carry out low-priority tasks when the event loop is not occupied with time-critical operations. Idle handles prove beneficial for tasks that require regular execution but do not demand immediate action or response to specific events.
  6. Before polling for I/O, prepare handle callbacks are executed to perform necessary tasks like updating data structures or configurations.
  7. The poll timeout is calculated before blocking for I/O in the loop. Here are the rules for calculating the timeout:
    - If the loop was run with the UV_RUN_NOWAIT flag, or if the loop is going to be stopped (uv_stop() was called), or if there are no active handles or requests, or if there are any idle handles active, or if any handles are pending to be closed, the timeout is 0.
    - If none of the above cases match, the timeout is set to the duration of the closest timer. If there are no active timers, the timeout is set to infinity.
  8. The loop blocks for I/O. At this point, the loop will block for I/O for the time calculated in the previous step. All handles related to I/O that were monitoring a specific file descriptor for a read or write operation will have their callbacks executed at this point.
  9. After I/O polling, the handle callbacks are executed immediately to handle setImmediate callbacks.
  10. Close callbacks are executed. These callbacks are scheduled for execution when libuv disposes of an active handle.
  11. The loop concept of ‘now’ is updated.
  12. Iteration ends.

Min heap is a data structure that guarantees the fast & easy access to minimum value in the heap. So if nearest expiring timer would 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 for the same time they are combined and backed by the single libuv timers. so 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.

https://nodejs.org/en/guides/event-loop-timers-and-nexttick

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():

  1. The primary use of process.nextTick() is for time-sensitive or high-priority operations that require prompt execution, bypassing the wait for other pending tasks.
  2. Allow users to handle errors, cleanup any then unneeded resources, or perhaps try the request again before the event loop continues.
  3. At times it’s necessary to allow a callback to run after the call stack has unwound but before the event loop continues.

In terms of output sequencing, the process.nextTick() callbacks will always be executed before the Promise callbacks.

  • process.nextTick() fires immediately on the same phase
  • setImmediate() fires on the following iteration or 'tick' of the event loop

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.

  1. First it’ll run sync user code i.e. for loop.
  2. The EventLoop proceeds to run the timer callbacks and discovers that the timer has completed and is now ready to run. As a result, it executes the timer i.e. setTimeout. In the console, we see “hello”.
  3. After that, the EventLoop moves on to the I/O callbacks stage. At this point, the file reading process is finished, but its callback is not yet marked to be executed because IO callbacks get queued up only at the IO polling step. That means even if the file reading is done the IO queue will still be empty as unless and until event loop comes at IO polling step the callback wont be inserted into IO queue. At this point, the readFile() callback event is collected and added to the I/O queue, but it still doesn’t get executed yet.
    It’s ready for execution, but EventLoop will execute it in the next cycle.
  4. Moving on to the next phase, EventLoop executes the setImmediate() callback. In the console, we see “immediate”.
  5. The EventLoop then starts over again. Since there are no timers to execute, it moves to the “Call pending callback stage”, where it finally finds and runs the readFile() callback. In the console, we see “world”.

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.

  1. The EventLoop proceeds to run the timer callbacks and discovers that the timer has completed and is now ready to run. As a result, it executes the timer. On the console, we observe the message “hello”.
  2. After that, the EventLoop moves on to the I/O callbacks stage. At this point, the file reading process is finished, but its callback is not yet marked to be executed. It will be marked later in this cycle. The event EventLoop then continues through several other stages and eventually reaches the I/O poll phase. At this point, the readFile() callback event is collected and added to the I/O queue, but it still doesn’t get executed yet.
    It’s ready for execution, but EventLoop will execute it in the next cycle.
  3. Moving on to the next phase, EventLoop executes the setImmediate() callback. In the console, we see “immediate”.
  4. The EventLoop then starts over again. Since there are no timers to execute, it moves to the I/O callbacks stage, where it finally finds and runs the readFile() callback.
    In the console, we see “world”.

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 quite straight forward as if we visualize 1st and 3rd line code are synchronous user code so that is ran 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 other call is blocking

What if the third call blocks the loop for 3 second will the second be called as we given it as 0 milisec?

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 third as even thought it is mentioned 0 milisec timeout, it is not guaranteed 0 sec as the user code will take the precedence. If user sync code is blocking the event loop the the timers gonna starve that's why its said that do not 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.

  1. The initial process.nextTick callback is executed first, logging 'nextTick 1' to the console.
  2. Within this callback, two more process.nextTick callbacks are scheduled: one logging 'nextTick 2' and another logging 'nextTick 5'.
  3. The callback logged as ‘nextTick 2’ is executed next, logging ‘nextTick 2’ to the console.
  4. Inside this callback, two more process.nextTick callbacks are scheduled: one logging 'nextTick 3' and another logging 'nextTick 4'.
  5. The callback logged as ‘nextTick 5’ is executed after ‘nextTick 2’, logging ‘nextTick 5’ to the console.
  6. Inside this callback, two more process.nextTick callbacks are scheduled: one logging 'nextTick 6' and another logging 'nextTick 7'.
  7. Finally, the remaining process.nextTick callbacks are executed in the order they were scheduled, logging 'nextTick 3', 'nextTick 4', 'nextTick 6', and 'nextTick 7' to the console.

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. And 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 founds 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

Before you go!

  • Stay tuned for more insights! Follow and subscribe.
  • Did you see what happens when you click and hold the clap 👏 button?

--

--