How does the Event Loop work in Node.js
Β© https://nodejs.org/en/

How does the Event Loop work in Node.js

One of the most important aspects to understand about Node.js

ByMario Kandut

honey pot logo

Europe’s developer-focused job platform

Let companies apply to you

Developer-focused, salary and tech stack upfront.

Just one profile, no job applications!

This article is based on Node v16.14.0.

The event loop is a fundamental concept in Node.js and is what allows Node.js to perform non-blocking I/O operations. How is this possible, when JavaScript is single-threaded?

What is the Event Loop?

πŸ’° The Pragmatic Programmer: journey to mastery. πŸ’° One of the best books in software development, sold over 200,000 times.

The event loop is offloading operations to the system kernel whenever possible, and allows Node.js to perform non-blocking I/O operations. Most modern kernels are multi-threaded (they can handle multiple operations at the same time). When one of these kernel operations completes, Node.js gets an update (from the Kernel), so a callback gets added to the poll queue to be eventually executed.

When Node.js starts, it initializes the event loop, processes the provided code (index.js or any other entry point for the application), which may make asynchronous calls, schedule timers (setTimeout, ...), calls proecss.nextTick(), etc. and then starts with processing the event loop.

The simplified diagram from the Node.js docs shows the event loop's order. Every box in the diagram is a phase in the event loop.

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”Œβ”€>β”‚           timers          β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  β”‚     pending callbacks     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  β”‚       idle, prepare       β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”‚   incoming:   β”‚
β”‚  β”‚           poll            β”‚<──────  connections, β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚   data, etc.  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚  β”‚           check           β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
└───      close callbacks      β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Every phase has a FIFO (first-in first-out) queue of callbacks to execute. In general, when the event loop enters a phase it performs any operations specific to that phase, then execute callbacks in that queue (of the phase) until it's done (the queue has been exhausted, or the maximum numbers of callbacks has been executed). Then the event loop will move to the next phase and continues again with executing callbacks, and so forth.

Event Loop Phases

The Event Loop is composed of several phases, which are repeated as long as the application has code that needs to be executed. In total there are seven or eight phases, depending on the OS, but only six phases are used by Node.js.

  1. timers: this phase executes callbacks scheduled by setTimeout() and setInterval().
  2. pending callbacks: executes I/O callbacks deferred to the next loop iteration.
  3. idle, prepare: only used internally.
  4. poll: retrieve new I/O events; execute I/O related callbacks (except close callbacks, and the ones scheduled by timers, and setImmediate()); node will block here when appropriate.
  5. check: setImmediate() callbacks are invoked here.
  6. close callbacks: some close callbacks, e.g. socket.on('close', ...).

Between each run of the event loop, Node.js checks if it is waiting for any asynchronous I/O or timers and shuts down cleanly if there are not any.

Phase 1: Timers

In Node.js timers are functions that execute callbacks after a set period. The core timers module provides two global functions: setTimeout(), and setInterval().

A timer specifies the threshold after which a provided callback may be executed rather than the exact time a person wants it to be executed. The callbacks of timers will run as early as they can be scheduled, after the set amount of time has passed. It could be that the OS scheduling, or the running of other callbacks will delay it.

Let's look at the example from the Node.js docs. We want to schedule a timeout with a 100 ms threshold, then a script starts asynchronously reading a file (which takes 95 ms):

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

When the event loop enters the poll phase, it has an empty queue, because fs.readFile() has not completed, so it will wait for the number of ms remaining until the soonest timer's threshold is reached. While it is waiting 95 ms pass, fs.readFile() finishes reading the file and its callback which takes 10 ms to complete is added to the poll queue and executed. When the callback finishes, there are no more callbacks in the queue, so the event loop will see that the threshold of the soonest timer has been reached and then gets back to the timers phase to execute the callback of the timer. In this example the total delay between the timer being scheduled and its callback being executed will be 105ms.

Executing timer callbacks as part of the Event Loop explains the non-obvious behaviour that a timer's wait time is not exact, it is a minimum time which will pass before the callback is queued for execution.

Phase 2: Pending Callbacks

When the application is waiting for a file to be read, it doesn't have to wait until the system gets back to it with the content of the file. It can do something else, since getting a coffee is not an option it will continue code execution and receive the file's content asynchronously when it is ready.

The asynchronous I/O request is recorded into the queue and then the main call stack can continue working as expected. In the second phase of the Event Loop the I/O callbacks of completed or errored out I/O operations are processed.

Let's look at an example:

fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
});

myAwesomeFunction();

The fs.readFile operation is a I/O operation. Node.js will pass the request to read a file to the filesystem of your OS. Then the code execution will immediately continue past the fs.readFile() code to myAwesomeFunction(). When the I/O operation is done(complete or error), a callback will be placed in the pending queue in the pending callbacks phase.

To prevent the poll phase from starving the event loop, libuv (the C library that implements the Node.js event loop and all of the asynchronous behaviors of the platform) also has a hard maximum (system dependent) before it stops polling for more events.

Phase 3: idle / waiting

This phase is only used internally for housekeeping. The Event Loop performs internal operations of any callbacks. It is not possible to have direct influence on this phase, or its duration and code execution is not guaranteed during this phase.

Phase 4: Poll

In this phase all the JavaScript code is executed, starting at the top of the file, and working down. Depending on the code it may execute immediately, or it may add something to the queue to be executed during a future tick of the Event Loop.

The poll phase has two main functions:

  • Calculating how long it should block and poll for I/O, then
  • Processing events in the poll queue.

When the event loop enters the poll phase and there are no timers scheduled:

  • If the poll queue is not empty, the event loop will iterate through its queue of callbacks executing them synchronously until the end (queue has been exhausted, or system-dependent hard limit is reached).
  • If the poll queue is empty, one of two more things will happen:
    1. If scripts have been scheduled by setImmediate(), the event loop will end the poll phase and continue to the check phase to execute those scheduled scripts.
    2. If scripts have not been scheduled by setImmediate(), the event loop will wait for callbacks to be added to the queue, then execute them immediately.

This phase may not happen on every tick of the event loop, depending on the application state.

Phase 5: Check

Node.js has a special timer setImmediate() and its callbacks are executed during this phase. setImmediate() allows executing callbacks immediately after the poll phase has completed. It uses a libuv API that schedules callbacks to execute after the poll phase has completed. Hence, the check phase runs as soon as the poll phase becomes idle.

If the poll phase becomes idle and scripts have been queued with setImmediate(), the event loop may continue to the check phase rather than waiting.

Scrips set with setImmediate() will always be executed before other timers regardless of how many timers are present.

Phase 6: Close Callbacks

This phase executes the callbacks of all close events. For example, a close event of web socket callback, or when process.exit() is called. This is when the Event Loop is wrapping up one cycle and is ready to move to the next one. It is primarily used to clean the state of the application.

Don't block the event loop

Node.js sends time-consuming operations (I/O callbacks) to the C++ API and its threads. This allows simulation of multithreading within a single-threaded Node.js process, and the main runtime can continue to execute code without waiting.

With this architecture Node.js can benefit of asynchronous non-blocking I/O interface without being a memory hoarder.

The following keynote from Bert Belder at the Node.js Interactive Amsterdam Conference in 2016 explains the event loop very well.