Skip to main content

Command Palette

Search for a command to run...

Event Loop of Node.js

Updated
•10 min read•View as Markdown
Event Loop of Node.js
B
I am a third-year undergrad student at Chandigarh University. I am into building backend and applied AI stuff using the modern technologies. I loves to document my learning through the blogs so that I can contribute to the community.

In the previous blog NodeJS Internals I had discussed about the architecture of NodeJS. How it is made and its core components.

In this blog we are going to go deep inside one of those feature that powers the working of NodeJS being single threaded i.e. Event Loop.

Event loops are the part of libuv in nodejs and its working is just awesome which runs into phases making NodeJS interviews tricky.

Different Phases of NodeJS Code

When we start a program using the command node index.js a process is created inside the OS and a thread is allocated to it. This thread is also called main thread in JavaScript which is responsible to execute all the tasks present in the call stack in LIFO order.

Now inside the main thread:

  • At first our project gets initialised.

  • Top level Code is executed(all the synchronous code including the imports) line by line. This code gets executed in the call stack of V8 Engine

  • Event Callbacks are just registered like:

    • setTimeout() registers a timer with libuv

    • fs.readFile() sends request to thread pool of libuv

      The callbacks are not executed yet but is passed to event loop

  • Now the event loop starts and something very crazy happens.

Event Loop

All the timers, async operations and closing callbacks are handled by event loop. All of them are divided into phases and are executed in a particular sequence. Each phase is a queue which follows the FIFO principle to execute the callbacks which comes under it.

Phases of the Event Loop

In the above image we have six boxes and each box represents different phases inside a event loop which runs in the same order and repeats until the current queues gets empty.

WHen a a event loops enters any of these phases two things happen:

  • It performs the internal operations for that phase

  • It executes the callback in that phase's queue

Example:

timers phase entered
↓
check which timers expired            -|
↓                                      | Internal Ops
add expired timer callbacks to queue  -|  
↓
execute them

Now the callbacks are in phase queues. Here all of them keeps executing until the phase's queues becomes empty or callback execution limit reached.

The limit has been introduced in node.js for the callbacks in the queue to prevent the event loop starvation. This means if there 10k callbacks in the timer queue then event loop will never reach the poll phase. So to avoid it nodejs internally executes all these callstacks in batches where some are executed now and others are executed in the next iteration. This keep the system fair so that everyone in the room get chance.

Now we will go through each phase and understand it one by one:

  1. timer's phase: this phase execute the callbacks scheduled by setTImeout() and setInterval(). The callbacks get into this queue only if the timer's delay has already expired.

  2. poll phase: This phase comes after the timer's phase and is also called as I/O phase where all the I/O related work is executed. This I/O task includes incoming HTTP requests, database responses, file reads, streams, etc.

  3. check phase: This handles the callback which is written in setImmediate()function. This runs after the poll phase fininishes.

  4. close callbacks: This phase runs the callbacks for resource that are closing like server.close(), socker.on(), streams.destroy()

In the image we see two more phases i.e pending callbacks phase and idle, prepare phase. Both of these phases are not normally used by app developers so I have not mentioned it here.

After checking all the phases, if all queues are empty it exits out of the event loop and the program ends successfully. But in some cases we don't want it to exit like, when we create a HTTP servers a socket always remain opened in the poll phase keeping the event loop active and thus the node stay alive forever.

The active handles not only includes the network sockets, it includes all the types asynchronous tasks.

timers

  • setTimeout(): The behaviour of setTimeout() is somewhere different as most people misunderstood it. If we write the following code:

    setTimeout(() => {
        console.log("Hello")
    }, 1000 
    

    People might think this callback will run exactly after 1000ms or 1s, but it is not true. Actually the above syntax just ensures it will take minimum of 1s to execute and there is no upper bound to it. We can understand this with some nice examples:

    setTimeout(() => {
      console.log("timer");
    }, 1000);
    
    while (true) {} //The synchronous infinite while loop code never allows the timer phase to come leading to no output
    

    If we do something like this:

    const start = Date.now();
    
    setTimeout(() => {
      console.log(Date.now() - start);
    }, 1000);
    //Output: 1002, 1014, 1004 -> Each time we run this we get different values. It happens because event loop might be busy in other tasks and in took some extra time to return back in timer phase which added some delay.
    

    Now let's get into more interesting example where poll phase delay the timers:

    fs.readFile("sample.txt", "utf8", () => {
        console.log("File reading finished")
    })
    
    setTimeout(() => {
      console.log("timer fired");
    }, 10);
    //Output: File reading finished
    //        timer fired
    

    In the above example, according to the sequence the timer "timer fired" should get logged first then "File reading finished" should be logged.
    But the result is not this because there is not synchronous code in the program making the event loop to get triggered in the start of the program.
    Now when the event loops enters the first step it sees no timer is expired but there is a timer registered in the all stack.
    Now we have taken a non existent file "sample.txt", reading a non existent file is almost sudden. Therefor as soon as the event loop enters the poll phase it executes file reading callback which prints the log "File reading finished"
    During this the 10ms timeout is already passed and the setTimeout is pushed to the timer queue. Now the event loop while doing the second iteration sees a async operation still pending. And here it executes it to give the log "Timer triggered".

    Important Gotchas of setTimeout():
    - setTimeout(0) Is Not Immediate: It stills runs the synchronous code first and then it will get handled by event loop.
    - Long CPU Tasks Break Timers: Example

    setTimeout(() => {
      console.log("timer");
    }, 100);
    
    for (let i = 0; i < 1e9; i++) {} //Blocks the event loop
    //Output: may be after 2-3s timer is logged
    
  • setInterval Drift: Example:

    setInterval(() => {
      console.log("tick", Date.now());
    }, 1000);
    //Ideally: tick 1000, tick 2000, tick 3000
    //Reality: tick 1000, tick 2100, tick 3200
    

    - Massive I/O Delays Timers: Example:

    10k incoming requests
    poll phase processing callbacks
    timers must wait
    

poll

The poll phase has two main functions:

  • Calculating how long it should block and polls for I/O, then

  • Processing events in the poll queue

Now understanding these two functions of the poll phase is really interesting.

When the event loop enters the poll phase, two things can happen:

  • First there is a timer which is running or pending to be executed:
    We all know the event loop do not stops at any phase.

    But it does stop in the poll phase.

    So let's take a scenario where a timer is running for 10s

    Event Loop: "There is no expired callback in the timer queue"

    Event Loop: "So let's move ahead to the poll phase"

    Event Loop : "Is there anything in the poll phase to execute"

    But there is nothing.

    Now the event loop will wait for 10s in the poll phase

    Event Loop: "I will wait at most 10s for I/O, after that I have to execute the timer callback"

    If no I/o reach during this time period, it simply continues the loop towards the check then close then timer and here it executes the callback.

    Even id a I/O arrives at 9.99s, it will execute this I/O first then it will move ahead.

    That's why we say setTimeout() don't run exactly after the timeout.

  • Another situation can happen when there are no timers and only poll phase has the work to do:
    Here everything will function normally. The poll phase will iterate over the callback queue and execute them synchronously until the queue is exhausted or the system dependent time limit has been reached.

check

Now after the poll phase the setTimeout() runs which comes under the check phase.

From the above context if a poll phase becomes idle and callbacks are started getting queued in the setImmediate() then it will leave the poll phase and enters the check phase to execute all the callbacks.

setImmediate() is separate timer which runs in a totally different environment as compared to the typical timers. Let's take a scenario to understand this:

setTimeout(() => {
    console.log("Set Timeout")
}, 0)

setImmediate(() => {
    console.log("Set Immediate")
})

console.log("Init")

//Output: Init
//        Set Timeout
//        Set Immediate

The above is simple depicting the flow - sync code -> timer -> Set Immediate

But I can give surprise to you with another example:

setTimeout(() => {
    console.log("Set Timeout")
}, 0)

setImmediate(() => {
    console.log("Set Immediate")
})

//Output: Set Immediate
//        Set Timeout

In the above example we see the check phase runs before the timer phase.

This happens because as soon as program starts event loop gets triggered because of the absence of sync code.

When it reaches the timer phase it sees no expired timers because setTimeout needs some time get registered and getting expired. The event loop is very fast therefore it skips it.

The event loop enters the check phase where it finds the callback to be executed and "Set Immediate" gets logged immediately.

Now while this happens the set timeout is successfully expired and is pushed to timer callback queue and in second iteration the loop executes this callback and we see "Set Timeout".

But what was happening earlier ?

Earlier, the sync code - console.log() actually gave time to the setTimeout() to expire hence it gets called first then setImmediate() was called.

NOTE: The advantage of setImmediate() over the setTImeout() is that setImmediate() will always be executed before any timers if it is scheduled within a I/O cycle.

process.nextTick()

process.nextTick() is not the part of the event loop. But holds more priority than any other phases of the event loop.

All the callbacks passed to process.nextTick() will be resolved before the event loop continues and after the event loop ends.

current JS code
↓
process.nextTick queue
↓
Promise microtasks
↓
event loop phases (timers, poll, check...)

It is mainly used to defer the execution without waiting for the event loop. Example:

function api(callback) {
  if (!callback) {
    process.nextTick(() => {
      throw new Error("Callback required");
    });
  }
}
//This ensures if the function dont have the callback then the event loop will not even start and the error will be thrown asycnrnoulsy.

NOTE: process.nextTick() can be used to block the event loop completely and not handled properly because it will run before the event loop even starts.

This process.nextTick() comes before the Promise Microtask queue of V8 Engine.

process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));

//nextTick
//promise

Macrotask

All the tasks handled by event loop phases are collectively called as Macrotasks.