Explore the Immediate Callbacks Phase of Node's Event Loop

When you think about Node.js timers and Node's Event Loop, you typically think about setTimeout() and setInterval(). If you need to execute code asynchronously, but as soon as possible, you can use a special timer available in Node.js: setImmediate(). This setImmediate() timer is so special that it has its own dedicated phase in Node's Event Loop.

In this tutorial we'll:

  • Learn how, and when, to use the setImmediate() timer
  • Understand the setImmediate() callbacks phase of Node's Event Loop through various code examples
  • Explore the difference between setTimeout() with 0 delay and setImmediate()

By the end of this tutorial you should have a better understanding of how setImmediate() works, and the role of the setImmedate() callbacks phase of Node's Event Loop.

Goal

Understand the behavior of setImmediate() and setTimeout() from the perspective of Node's Event Loop.

Prerequisites

Schedule immediate synchronous callbacks with setImmediate()

When you want to execute a callback function asynchronously, but as soon as possible, one option is to use the setImmediate() timer provided by Node.js. setImmediate() takes one argument: a callback function to be executed.

Let's see it in code. Create a file named immediate.js and put a setImmediate() call in it. Your code may look something like this:

console.log('Start Polling');

setImmediate(() => {
  console.log('Immediate!');
});

console.log('End Polling');

Run the code with the following command:

$ node immediate.js

And observe the results:

$ node immediate.js
Start polling
End polling
Immediate!

Let's see what is happening here from the Event Loop perspective. The Event Loop got past the "timers", "I/O callbacks", and "prepare" phases and came into the "polling" phase. There it executed the first console.log() statement, called setImmediate(), and pushed its callback into the setImmediate() callbacks queue.

Then there was no more code to execute, so the "polling" phase became idle. Then we moved to the setImmediate() callbacks phase. In this phase the queued callback executed and we printed "Immediate!".

Understanding setImmediate() vs setTimeout()

Let's now imagine that we also have a setTimeout() timer. To better understand the connection between the execution time of those two timers we will start by modifying our code to include a basic setTimeout() timer, with a one second delay, that is called before setImmediate(). Your code may look something like this:

console.log('Starting');

setTimeout(() => {
  console.log('Timeout!');
}, 1000);

setImmediate(() => {
  console.log('Immediate!');
}); 

Run the code and observe the results:

$ node immediate.js
$ node immediate.js
Starting
Immediate!
Timeout!

We can see that at first Node.js skips the "timers" phase (we don't have any timers callbacks set yet). Node goes right into the "polling" phase where we hit our first console.log() and outputs "Starting". Then Node reaches the setTimeout() call, and its callback is pushed into the timers queue. Next the setImmediate() call puts its callback into the setImmediate() callbacks queue.

The polling phase is now done and libUV checks if there are callbacks in the setImmediate() callbacks queue. Node proceeds into this phase and runs the code that prints "Immediate!". After that, on the next tick of the Event Loop, we reach the timers phase again and execute the code that outputs "Timeout!".

So far everything is happening as expected. Let's now modify the code and test what will happen if we call setTimeout() with 0 second delay.

Here is the updated immediate.js code:

console.log('Starting');

setTimeout(() => {
  console.log('Timeout!');
}, 0);

setImmediate(() => {
  console.log('Immediate!');
});

Before we go ahead and execute the code let's take a second to make a guess about what we will see based on what we just learned.

You might expect the output in the console to be as follows:

Starting
Immediate!
Timeout!

Node gets to the "polling" phase, then puts setTimeout() callback into the timers queue and setImmediate() callbacks into the setImmediate() callbacks queue. Then setImmediate() callbacks are executed followed by the setTimeout() callback during the next tick's "timers" phase of the Event Loop.

Let's run the code a couple times with node immediate.js command.

$ node immediate.js
Starting
Timeout!
Immediate!

$ node immediate.js
Starting
Immediate!
Timeout!


$ node immediate.js
Starting
Immediate!
Timeout!

As you can see, our prediction didn't come true, and the output order is somewhat random. But why? If we check in the Node.js documentation it explains that the order in which these two timers are executed when scheduled during the "polling" phase is non-deterministic and is bound by the performance of the main process.

But what does that mean? When we are in the "polling" phase, the Event Loop only moves on to the setImmediate() callbacks phase if the scheduled setImmediate() callbacks and the "polling" phase become idle -- there are no more callbacks or code to be executed. In order to not starve the Event Loop it also will move on to the setImmediate() phase when the internal timer for polling (allowed polling time frame) is up. If the polling time frame is not up, the event loop isn't moving into the setImmediate() phase.

What happened in our example? When we reached the "polling" phase we set a setTimeout() callback into the timers queue, then we set a setImmediate() callback into the queue. At that moment, before we were able to move on from "polling" to the next phase, the setTimeout() time has elapsed (since it had 0 delay) and the "polling" phase switched back to the "timers" phase to call the timeout callback. And we see "Timeout!". Then it moved through the phases into the setImmediate() callbacks phase and we see "Immediate!".

So far so good. But let's dig a bit deeper and try to schedule timers during the "I/O callbacks" phase. Modify your code to look something like this:

const fs = require('fs');
console.log('Starting');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('Timeout!');
  }, 0);
  setImmediate(() => {
    console.log('Immediate!');
  });
}); 

Note: the __filename variable above resolves to the full path to the current file. We are using it here as a convenience for reading an existing file to force the I/O phase; we don't actually care what file we read. As you can see here we require the fs module to allow for file operations. Then we call setTimeout() and setImmediate() from its callback.

Let's now run the code a couple times with node immediate.js command.

$ node immediate.js
Starting
Immediate!
Timeout!

$ node immediate.js
Starting
Immediate!
Timeout!

$ node immediate.js
Starting
Immediate!
Timeout!

As you can see now setImmediate() runs first and setTimeout() second. Let's go step by step and see what is happening in the the Event Loop. Node steps into the code and hits the first console.log() statement that prints ("Starting"). This happens during the "polling" phase of the first Event Loop -- Node skipped over other phases since it didn't have any timers callbacks or I/O callbacks yet. Then it starts reading the file. This is an asynchronous call and its callback is sent to the I/O callbacks queue. Then Node enters the setImmediate() phase; there are no callbacks there yet, so it moves to the "closing" phase.

On the next tick Node continues back to the "timers" phase, skips over it as there are still no callbacks there, moves on to the "I/O callbacks" phase and gets our callback. During its execution we set call setTimeout() and set a callback in the timers queue, and a setImmediate() callback into the setImmediate() callbacks queue. Then Node moves on to the "internal" phase and into the "polling" phase.

The polling phase's internal polling timeout is set to zero since Node.js detects that we have a setImmediate() callback in the queue. So it skips over polling and goes directly to the setImmediate() callbacks phase, and the "Immediate!" log statement appears. After that Node continues to the next phases, and again reaches the "timers" phase, at which point it executes the setTimeout() callbacks and we see "Timeout!" printed in the console.

The main advantage to using setImmediate() over setTimeout() is that setImmediate() will always be executed before any timers if scheduled within an I/O cycle, regardless of how many other timers are present and their elapsed delay time. This differs from when it is set in the "polling" phase, where we don't have any setImmediate() callbacks yet, and the polling timer may not be set to 0. In this case, the delay of other timers may be elapsed prior to the Event Loop moving into the setImmediate() callbacks phase. This, in turn, will lead to those timers' callbacks being executed prior to our setImmediate() callback.

Recap

If you want to execute asynchronous callbacks without specifying a delay, one method is to use the special setImmediate() timer. setImmediate() has its own phase in the Event Loop. Its main advantage is allowing you to schedule a callback within the I/O cycle. In this scenario it guarantees your callback will run first, regardless of any other timers and their delay time.

Further your understanding

  • We covered setTimeout() and setImmediate(). Try to schedule a callback with process.nextTick(). What do you see in terms of the order of execution?
  • Read about process.nextTick(), setTimeout() and setImmediate() in the Node.js documentation.

Additional resources

Sign in with your Osio Labs account
to gain instant access to our entire library.