A Deeper Look at NodeJS Event Loop Phases

With Code Example

The JavaScript that runs in your browser uses an event loop.

So does the JavaScript that runs in a NodeJS environment.

Though JavaScript is single-threaded, it can still handle concurrency. This is made possible by the event loop.

Coupled with the reactor pattern of NodeJS, the event loop allows JavaScript to handle multiple tasks in a single-thread.

While we saw how the event loop fits into the big picture as part of the earlier post on NodeJS concurrency, it is time to give a better look at the various phases of the event loop in NodeJS.

NodeJS Event Loop Phases

Often, the event loop in NodeJS is thought be like a big queue of events and their associated callbacks. The basic idea is that the event loop simply loops through these events and triggers the callbacks one-by-one.

However, things are a bit more complicated.

In reality, the NodeJS event loop goes through several different phases. Each of these phases maintains its own queue of callbacks.

When an application runs, it can generate several types of callbacks. Each callback is destined for a specific phase depending on how they are used within the application.

See the below illustration that shows the various phases of the event loop:

image.png

When the application starts running, the event loop also begins and starts handling the phases one after the other.

NodeJS adds callbacks to the queues of different phases as and when appropriate. When the event loop reaches a particular phase, the callbacks within that phase are executed.

Let us look at each of these phases.

Poll Phase

The poll phase executes I/O related callbacks. When our application starts running, the event loop is in the poll phase.

In fact, most of the application code that we write is likely to be executed in this particular phase.

Check Phase

In this phase, callbacks triggered via setImmediate() are executed.

Close Phase

The close phase executes callbacks triggered via the EventEmitter close events. For example when the TCP server closes, it emits a close event that triggers a callback in the close phase.

Timers Phase

Any callbacks scheduled using setTimeout() and setInterval() are executed in the Timers phase.

Pending Phase

Any special system events are run in the Pending phase. For example, a TCP socket throwing a connection refused error.

Microtask Queues

Apart from the above major phases, there are two special microtask queues as well. These queues can receive callbacks when a phase is running.

The first microtask queue handles callbacks registered using process.nextTick().

The second microtask queue handles promises that reject or resolve

“Where do these queues fit into the big picture?” you ask.

The microtask queues are checked after every phase. In other words, the microtask queue callbacks take priority over other callbacks in the normal phase queues.

Also, callbacks in the next tick microtask queue take priority over callbacks in the promise microtask queue.

The Event Loop Phases in Action

Discussing the phases theoretically is fine but it cannot solidify our understanding.

Time to look at the NodeJS event loop phases in action.

Check out the below code where we do a bunch of random stuff to demonstrate how the loop actually works:

const fs = require('fs');
setImmediate(() => console.log(1));
Promise.resolve().then(() => console.log(2));
process.nextTick(() => console.log(3));
fs.readFile('package.json', () => {
  console.log(4);
  setTimeout(() => console.log(5));
  setImmediate(() => console.log(6));
  process.nextTick(() => console.log(7));
});
console.log(8);

Can you guess the output?

Let us walkthrough the entire code.

  • The program starts executing in the Poll phase. In the first line, we require the fs module. This is followed by a call to setImmediate().
  • setImmediate() might sound urgent but it does not execute immediately. The callback printing 1 to the console is added to Check phase queue and the execution moves further.
  • Next, the promise resolves but nothing happens immediately. The callback printing 2 is added to the promise microtask queue.
  • The process.nextTick() is the next to execute and it adds the callback 3 to the next tick microtask queue.
  • Moving on, we have the fs.readFile() call. It basically tells the NodeJS core API to start reading a file named package.json. The callback will be placed in the poll queue once it’s ready.
  • At the end of the program, we have the log number 8 that is called directly and is printed to the screen. The current stack ends at this point.
  • Moving on, the two microtask queues are consulted. As we discussed earlier, the next tick microtask queue is checked first and callback 3 is executed. Followed by this, the promise microtask queue is checked and callback 2 is printed. With the two microtask queues finished, we are done with the current poll phase.
  • After the poll phase, the check phase starts. There is only one callback in this phase - the setImmediate() callback that prints 1 to the console. The microtask queues are empty at this point and therefore, the check phase also ends.
  • The close phase is next. But there are no callbacks in queue and hence, the event loop moves further. The same happens to the timers and the pending phases. At this point, one iteration of the event loop ends and we are back in the poll phase.
  • In the poll phase, the application does not have much else going on. The application basically hangs around in this phase waiting until the package.json file has finished being read. Once that happens, the fs.readFile() callback is executed.
  • Within the callback, the console.log() immediately prints 4.
  • Next, the setTimeout() runs and the callback 5 is added to the timers queue. The setImmediate() call adds the callback 6 to the check queue.
  • At the end, the process.nextTick() call adds the callback 7 to the next tick microtask queue. This is the end of the poll queue.
  • The next tick microtask queue is checked and 7 is printed. The promise microtask queue is empty and therefore, the loop enters the check phase.
  • The check phase begins and callback 6 is printed. The microtask queues are empty this time and the loop moves further.
  • The close phase is found empty.
  • Next, the timers phase begins where the callback 5 from setTimeout() is executed. With this, the application has no more work left to do and it finally exits.

The output prints as below:

8
3
2
1
4
7
6
5

Conclusion

It is true that in normal application development, we may not require knowing about the event loop at such a level of depth. In most cases, things just work.

But as you can see, even small changes to the way we structure our callbacks can alter their execution order. Depending on how our program is structured, it can have an impact on the eventual output.

Knowing about the event loop phases in NodeJS can help determine how sure we are about our application’s output.

Of course, to make our understanding even more concrete, we also need to understand how callbacks works in NodeJS.

Did you find this article valuable?

Support Saurabh Dashora by becoming a sponsor. Any amount is appreciated!