How does the Node Event loop work?

This article is aimed at explaining how the Node event loop work and the phases involved.

How does the Node Event loop work?

Introduction

Node.js is a runtime environment built on the google chrome V8 engine. It provides an event-driven, non-blocking, and I/O cross-platform to build scalable server-side applications using Javascript. The event loop is a fundamental concept of Node.js, it is the secret behind Node.js's asynchronous programming.

Goals

By the end of this article, you will have a good understanding of the event loop and its phases. We will also discuss how Node utilizes the underlying C library to perform non-blocking operations

Prerequisite

  • Basic understanding of Node.js

Terms and Definition

We will be using the following terms in this article;

  • Tick: an iteration of the event loop.

  • Timer: a function that executes code after a set period of time

  • Process: the process starts when the application starts execution. The process starts when we run node index.js

  • I/O: any task that involves the external hardware/OS: file system, network
    operations, etc.

  • Thread: this presents a linear series of instruction in the process to be executed

Processes and Threads.

Before we take a look at how the event loop works, let us take a brief detour and discuss processes and threads.

Node.js runs in a single process, a single thread is created in that process, and this is where our application runs. The event loop is situated inside the single thread. event-loop.drawio.png

This design has its flaw because our application will not make use of more than one core of the CPU to process requests. This can block, and slow down execution when it encounters a processing-heavy task in the application. Let's discuss how the event loop solves this issue.

When we run node index.js, the execution of our code starts. When the event loop encounters a processing-heavy I/O task, it immediately offloads the task to the underlying C library called the libuv, and registers a callback in the event queue.

The event loop does not wait for the current task to be completed, it simply moves on to the next task. When the I/O operation is processed, the event loop calls the respective callback and returns a response. This is how the event loop allows Node to perform non-blocking operations.

Event Loop and its phases

The event loop consists of six phases:

          ┌───────────────────────────┐
        ┌─>│           poll            
        │  └─────────────┬─────────────┘
        │  ┌─────────────┴─────────────┐
        │  │           check         
        │  └─────────────┬─────────────┘
        │  ┌─────────────┴─────────────┐
        │  │       close callbacks    
        │  └─────────────┬─────────────┘
        │  ┌─────────────┴─────────────┐
        │  │           timers          
        │  └─────────────┬─────────────┘
        │  ┌─────────────┴─────────────┐
        │  │     pending callbacks    
        │  └─────────────┬─────────────┘
        │  ┌─────────────┴─────────────┐
        └──┤        idle, prepare     
           └───────────────────────────┘

Below is an overview of each phase from the Node docs .

  • timers: this phase executes callbacks scheduled by setTimeout() and setInterval().
  • I/O callbacks: executes almost all callbacks with the exception of close callbacks, the ones scheduled by timers, and setImmediate().
  • idle, prepare: only used internally.
  • poll: retrieve new I/O events; node will block here when appropriate.
  • check: setImmediate() callbacks are invoked here.
  • close callbacks: such as socket.on(‘close’).

Each phase has a FIFO queue of callbacks to execute. While each phase is special in its own way, generally, when the event loop enters a given phase, it will perform any operations specific to that phase, then execute callbacks in that phase's queue until the queue has been exhausted or the maximum number of callbacks has executed. When the queue has been exhausted or the callback limit is reached, the event loop will move to the next phase, and so on. Source :- Node docs

Phases in Detail

timers

A timer specifies a threshold after which the callback is executed. The timer phase is executed directly by the event loop.

Timer callback does not run after the delay in milliseconds has passed, they run as early as scheduled or after running other callbacks.

This code snippet demonstrates the extra delay in running a callback of a timer function.

let start = Date.now()
setTimeout(() => console.log(Date.now() - start), 1000)     //1017
setTimeout(() => console.log(Date.now() - start), 1000)     //1042

I/O callbacks

This phase executes callbacks for some system operations such as types of TCP errors. For example if a TCP socket receives ECONNREFUSED when attempting to connect. Some system operations want to wait before executing a callback, this phase is used for that. This will be queued to execute in the pending callbacks phase.

idle, prepare

This phase is used internally.

Poll phase

The poll phase calculates the blocking time in every iteration to handle I/O callbacks, and processes events in the poll queue. This phase is where most of our code is executed.

check

The check phase handles any callback defined inside setImmediate(). Callbacks in this phase will execute immediately after the poll phase has completed or become idle. setImmediate() is actually a special timer that runs in a separate phase of the event loop.

close callbacks

This phase handles callbacks If a socket or handle is closed abruptly ( socket.close() ) , the 'close' event will be emitted in this phase. When there are no more callbacks in any phases, the close callbacks phase calls the process.exit() and exits to the terminal.

Further abstractions

process.nextTick()

After the execution of each phase of the event loop and before moving to the next phase there is a nextTickQueue that is executed but isn’t officially a part of the event loop.

Any callback provided in the process.nextTick gets registered in the nextTickQueue of the next phase. Executing long-running codes in the nextTickQueue can starve the execution of different phases of the event loop’s iteration.

The code below demonstrate how the process.nextTick works.

setImmediate(() => console.log('Hello setImmediate'))
console.log('Hello world')
process.nextTick(() => {
  console.log('Hello nextTick')
})

We get this output

           ┌───────────────────────────┐
        ┌─>│    poll     │---> prints Hello world
        │  └─────────────┬─────────────┘
        │    nextTickQueue  ---> prints Hello nextTick
        │  ┌─────────────┴─────────────┐
        │  │   check  │ ---> prints Hello setImmediate
        │  └─────────────┬─────────────┘
        │           nextTickQueue
        │  ┌─────────────┴─────────────┐
        │  │       close callbacks     │
        │  └─────────────┬─────────────┘
 nextTickQueue      nextTickQueue
        │  ┌─────────────┴─────────────┐
        │  │           timers          │
        │  └─────────────┬─────────────┘
        │           nextTickQueue
        │  ┌─────────────┴─────────────┐
        │  │     pending callbacks     │
        │  └─────────────┬─────────────┘
        │           nextTickQueue
        │  ┌─────────────┴─────────────┐
        └──┤        idle, prepare      │
           └───────────────────────────┘

Conclusion

In this article, we have learned how event loop works, and the phases involved. This will enable us to write more performance code.

credits

Node.js Official docs