Explore the Timers Phase of Node's Event Loop

When working with Node.js it's essential to understand Node's Event Loop and its phases. The first phase of Node's Event Loop is timers, and their callbacks. Understanding timers, and when their callbacks are called, is the first step to understanding scheduled code execution. It allows us to better control the time continuum of our application. In Node.js timers can be set with two main functions setTimeout() and setInterval().

In this tutorial we will:

  • Further our knowledge of the timers phase of the Node.js Event Loop through exploration of code examples.
  • Learn the concept of code scheduling and how to do it with setTimeout() and setInterval().
  • Gain an understanding of callbacks execution time and differences between timers in the context of Node.js and the browser.

By the end of this tutorial you should be able to explain how timers created with setTimeout() and setInterval() relate to the Node.js Event Loop.

Goal

Learn how to use setTimeout() and setInterval() in your code and observe execution of their callbacks during the Event Loop execution.

Prerequisites

Scheduling a one time callback with setTimeout()

setTimeout() is a one-time timer that allows us to schedule code to be run after a certain period of time.

Example:

setTimeout(function, delay, [arg1], [arg2], ...);

The setTimeout() function takes two main arguments:

  • callback: The callback function that needs to be called when the delay time is up
  • delay: The number of milliseconds to delay before calling callback

Any additional arguments will be passed to the callback function when it executes.

Imagine that you are at the movie theatre, and are about to watch a movie. You are excited, but you're stuck watching the pre-show as the movie attendant is waiting for the cue to run the movie.

Let's run this scenario in the console. Create a file called timers.js. In this file define the variable alert that will tell the movie attendant to start the movie when the pre-show waiting period is up. Add a timer with the delay set to the length of the pre-show.

Your code may look something like this:

const alert = "We are ready; start the movie";

// This is what we see in the console first.
console.log("This is a pre-show ad reel... It's running");

// setTimeout() function with anonymous callback
// and delay in milliseconds.
setTimeout(() => {
  console.log(alert);
}, 5000);

Once ready, run your code with the command:

$ node timers.js

In the console at first we see our pre-show demo reel text:

$ node timers.js
This is a pre-show ad reel... It\'s running

Then after the delay we see that our movie attendant got the signal and the movie is about to start:

$ node timers.js
This is a pre-show ad reel... It\'s running
We are ready; start the movie

Let's look at what happened here from the perspective of the Event Loop. Our application process started and entered the Event Loop. It went to the first phase -- "timers" -- where it checks for timer callbacks. At the beginning we didn't have any; our setTimeout() is further down the code pipe line.

The Event Loop moved on to further phases: "I/O callbacks", "idle", and finally, "polling". In the "polling" phase Event Loop executed our first console.log() with the ad reel text. Then it hit the call to setTimeout() and pushed its callback to the timers queue and proceeded to the next phases of the Event Loop.

Since we didn't have any other code after the setTimeout(), but we're still waiting on the timer that's currently in the queue, the code went idle on the setImmediate() callbacks phase. The Event Loop went to sleep and was woken up when the timer's delay was up. Then it started the next tick with the timers phase and executed the callback.

After that, it continued through the Event Loop phases until it hit the setImmediate() phase again, realized that there is no more code to be executed, and nothing in the timers queue. Thus it moved on to the "close events" phase and executed process.exit() finalizing the runtime of our application.

The catch here is to understand that the delay passed into setTimeout() is a minimum delay and it may take more time before the callback is executed. Let's say the movie is supposed to start at 2:30 PM, and the length of our ad reel is 10 mins. It means the movie assistant needs to start the ad reel at 2:20 sharp -- but today they weren't feeling that well and didn't get the ad reel started until 2:25. They cannot start the movie until the ad reel is finished because sponsors paid for the ads to be seen. So the movie will not be able to start at 2:20, even though the time was essentially up.

Let's see this in code. In the timers.js file, change the delay for our timer to be 10 milliseconds -- so short, it's almost immediate -- and run your code. Notice that you almost can't tell the difference in the console, and the two console messages follow each other almost without any delay. Now let's emulate the ad reel delay. Create a function with the following code and call it after the setTimeout():

Edit timers.js:

const alert = "We are ready, start the movie";

// Emulation of the demo (ad) reel.
const demoReel = () => {
  const length = 500000000;
  let sum = 0;
  for (i = 0; i <= length; i++) {
    sum += i;
  }
};
// setTimeout() function with anonymous callback
// and delay in milliseconds.
setTimeout(() => {
  console.log(alert);
}, 10);
demoReel();

Then run the code with the following command:

$ node timers.js

Notice how in the console our "movie is ready to start" message is no longer displayed instantly. The timer callback is in the queue of the timers phase of the Event Loop but the demoReel() function blocked the stack at the "polling" phase while the for loop was running.

The timer is waiting until it's executed, then the setImmediate() phase happens; the Event Loop sees that the timer's delay is up, it goes back at the "timers" phase and can process the callback for our setTimeout().

Scheduling repeatable callbacks with setInterval()

In situations when you need to be able to execute a block of code repeatedly, the setInterval() timer is a better option. It takes the same arguments as setTimeout(): a callback function and delay in milliseconds. The internal implementation of setInterval() is close to setTimeout() with the exception of the internal repeat flag which is set to true for setInterval().

Let's get back to the movie theatre. Imagine we were arguing about what movie to watch and ended up being impossibly late. All of the seats seem to be sold out and it looks likely that we are going to miss out. However, it's common for this theatre to release a couple tickets close to the beginning of the show online. We would like to check if there are any seats available, once every second, and if there are two or more then book the tickets. The code to do that looks something like the following.

Edit the timers.js file so that it contains this code:

// This function emulates us checking available seats.
const getEmptySeats = () => {
  return Math.round(3 * Math.random());
};

// We would like to check every second.
setInterval(() => {
  let seats = getEmptySeats();
  if (seats === 1) {
    console.log(`There is currently ${seats} seat available`);
  } else if (seats === 0) {
    console.log(
      "Oh nooooo, no available seats, looks like we will miss the movie!"
    );
  } else if (seats >= 2) {
    console.log(`${seats} seats are available, book them, quickly!`);
  }
}, 1000);

We created an emulation randomizer function at the top that allows us to simulate results of checking available seats online. It will return numbers from 0 to 3. Let's run our code and check the result.

In the terminal run:

node timers.js
2 seats are available, book them, quickly!
3 seats are available, book them, quickly!
3 seats are available, book them, quickly!
Oh nooooo, no available seats, looks like we will miss the movie!
2 seats are available, book them, quickly!
2 seats are available, book them, quickly!
2 seats are available, book them, quickly!
There is currently 1 seat available

This code will run until you exit the process manually with CTRL-C.

As you can see, the timer is running and we are seeing the console output. How does it work in terms of the Event Loop? As with setTimeout() we call setInterval() and it puts its callback function into the timers phase queue. The next time the Event Loop is in the timers phase it will execute this callback, provided the delay time is up. During the polling phase the callback will be re-added to the queue repeatedly due to the repeat flag.

When you ran the code you saw that the process did not stop no matter how much time has elapsed since the beginning of the execution. In the real world we would have already booked the tickets when we saw the first message and gone on to watch the movie. The reason the timer is not stopping is because libUV checks during the "polling" phase if it has any outstanding callbacks. Since it sees setInterval() it never exits the Event Loop and our program execution never ends.

If we want to stop checking as soon as our need for tickets is satisfied we need to tell our application that the timer should stop execution. There are many ways of doing this. Let's explore them all in relation to the Event Loop.

First we can use process.exit() to stop the process. Let's modify our code in timers.js to illustrate this.

const getEmptySeats = () => {
  return Math.round(3 * Math.random());
};
// We would like to check every second.
setInterval(() => {
  let seats = getEmptySeats();
  if (seats === 1) {
    console.log(`There is currently ${seats} seat available`);
  } else if (seats === 0) {
    console.log(
      "Oh nooooo, no available seats, looks like we will miss the movie!"
    );
  } else if (seats >= 2) {
    console.log(`${seats} seats are available, book them, quickly!`);
    process.exit();
  }
}, 1000);

Notice that we have process.exit() in our successful condition. Let's run the code in the terminal:

$ node timers.js
$ node timers.js
3 seats are available, book them, quickly!
$

It works! However, this solution would only be acceptable if we were late for the movie since here we are killing the whole runtime execution -- not the most elegant way to end something.

From the Event Loop perspective -- it's terminated alongside the entire process / application runtime.

Node.js has mechanisms that allow you to clear the timer. Edit the timers.js file again:

const getEmptySeats = () => {
  return Math.round(3 * Math.random());
};
const timer = setInterval(() => {
  let seats = getEmptySeats();
  if (seats === 1) {
    console.log(`There is currently ${seats} seat available`);
  } else if (seats === 0) {
    console.log(
      "Oh nooooo, no available seats, looks like we will miss the movie!"
    );
  } else if (seats >= 2) {
    console.log(`${seats} seats are available, book them, quickly!`);
    clearInterval(timer);
  }
}, 1000);

First, we need to keep a reference to our timer. Do this by assigning the return value of setInterval() to a variable. Then when we're ready to clear the timer we pass this reference to the clearInterval() function.

Run it in your terminal with:

$ node timers.js
$ node timers.js
There is currently 1 seat available
2 seats are available, book them, quickly!
$

What happened here from the Event Loop perspective? The Event Loop hit the "polling" phase and added our setInterval() callback to the timers queue. When the delay was up and the Event Loop was in the "timers" phase the callback was executed and we saw the console message: "There is currently 1 seat available". This didn't meet our needs and the Event Loop proceeded to further stages, back to polling where the callback was sent to the timers queue again.

This time, when the delay was up during the timers phase the callback was called again and the result of the check was positive -- we saw 2 seats available. We ran to book the tickets, meanwhile telling libUV that the timer can be cleared so its callback didn't end up in the timers queue one more time. The Event Loop went through all the remaining phases, didn't find any more code to execute, and terminated the process execution.

So far so good, but there is an even more elegant Node.js-specific way of stopping the timer. Note, that unlike clearInterval() this won't work in a browser. In Node.js, unlike the browser, timer is an object and when it's called it's referenced in libUV. In the timers phase of the Event Loop all referenced timers are checked for callbacks. If we unreference the timer we are telling libUV that this timer and its callback are not important anymore and can be ignored during the timers phase.

Let's see this in action. We will still need reference to the timer in the beginning. Later on, instead of calling clearInterval() we will call the unref() of the timer object.

// This function emulates us checking available seats.
const getEmptySeats = () => {
  return Math.round(3 * Math.random());
};

// We would like to check every second.
const timer = setInterval(() => {
  let seats = getEmptySeats();
  if (seats === 1) {
    console.log(`There is currently ${seats} seat available`);
  } else if (seats === 0) {
    console.log(
      "Oh nooooo, no available seats, looks like we will miss the movie!"
    );
  } else if (seats >= 2) {
    console.log(seats + " seats are available, book them, quickly!");
    timer.unref();
  }
}, 1000);
$ node timers.js
There is currently 1 seat available
There is currently 1 seat available
Oh nooooo, no available seats, looks like we will miss the movie!
2 seats are available, book them, quickly!
$

As you can see, here we ran through the callback a few times until we hit the condition that met our needs. In this case we told libUV that we don't care about this timer anymore. The timer continued to run but during the "polling" phase its callback wasn't marked as outstanding and put in the timers queue. The runtime execution detected that there was no more outstanding code and the process hit an exit.

Both methods, clearInterval() and timer.unref(), are acceptable. However, the clearInterval() function completely kills timer execution, preventing the callback from running. The timer.unref() function acts more like a flag. While the Event Loop is active the timer callback will run as normal, but if the timer is the last thing in the active queue the Event Loop will not run it and will exit.

The magical thing about the timer.unref() method is that the timer may be marked as active again with the use of its sibling function timer.ref() called on a timer object.

Use the clearInterval() / clearTimeout() function when you want to completely stop the timer; use timer.unref() when you want to keep it running but without being active. But make sure you don't overuse this method; a lot of timers running in the background may impact the performance of your application.

In most cases, clearInterval() and clearTimeout() are what you will want to use. Using unref() can lead to unpredictable behavior in your application where callbacks may or may not be executed one more time based on the state of the Event Loop.

Recap

In this tutorial we dove into the timers phase of the Event Loop through various examples. We learned that to schedule one-time execution of a callback, use setTimeout(). To schedule repeated execution of a callback, use setInterval(). It is important to understand that the polling phase in reality controls when each timer callback is run because slow code can block execution. The delay time that is passed to the timer function is a minimum time that will pass prior to the callback being executed. Finally, infinite timers need to be cleared or unreferenced, otherwise they will continue to run until the process finishes up with the exit callback.

Further your understanding

  • We covered timers in the timers phase of Node's Event Loop. Try using setImmediate() timer instead of the setTimeout() to see the difference of execution.
  • Try setting setTimeout() with 0 delay and setImmediate(), and observe the execution. Run the program multiple times.

Additional resources

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