JavaScript Event Loop: microtasks and macrotasks

The execution flow of browser JavaScript relies on an event loop. It is essential for optimization and the correct architecture.

In this cover, you will first find the theory and then the practical applications.

Event Loop

Event loop has a straightforward concept. There exists an endless loop when the engine of JavaScript waits for tasks, runs them and then sleeps expecting more tasks.

The engine’s primary algorithm is as follows:

  1. Once there are tasks: executing them, beginning with the oldest one.
  2. Sleep till a task turns out, then get to 1.

However, the JavaScript engine doesn’t do anything most of the time. It only runs when a handler, script, or an event triggers.

The examples of tasks can be the following:

  1. Once an external script <script src="..."> loads, the task is executing it.
  2. Once the user moves the mouse, the task is dispatching the mousemove event, executing handlers.
  3. Once the time has come for setTimeout, the task is running its callback.

More examples can be counted.

Once the tasks are set, the engine handles them, expecting more tasks while sleeping.

A task may come while the engine is busy, but it will be enqueued. As illustrated above, when the engine is busy running a script, the user can move the mouse, leading to mousemove , and setTimeout can be due. Also note, that while the engine is executing a task, rendering will not happen. Only after the task is complete, the changes are painted in the DOM. In case a tasks lasts too long, the browser is not able to carry out other tasks. After a long time it will alert “Page Unresponsive” offering to kill the task. That takes place once there are multiple complex calculations or a programming error that can bring an infinite loop.

So, you got acquainted with the theory, now let’s see what happens in practice.

Splitting CPU-hungry Tasks

Imagine having a CPU-hungry task.

For instance, syntax- highlighting is considered CPU-hungry. For highlighting the code, it implements analysis, generates multiple colored elements, places them to the document.

When JavaScript engine is busy with highlighting, it can’t do any DOM-related tasks, process user events, and so on.

To avoid problems, you can split a large task into pieces.

Start at highlighting 100 lines, then schedule setTimeout for the following 100 lines.

For illustrating that approach, let’s consider a function, which counts from 1 to 1000000000:

let t = 0;
let start = Date.now(); 
function countDate() {
  for (let j = 0; j < 1e9; j++) {
    t++;
  }
  console.log("Done in " + (Date.now() - start) + 'ms');
}
countDate();

After running the code above, the code will hang for a while.

The browser can even demonstrate “ the script takes too long” alert.

You have the option of splitting the job with nested setTimeout calls like this:

let t = 0;
let start = Date.now();
function countDate() {
  do {
    t++;
  } while (t % 1e6 != 0);
  if (t == 1e9) {
    console.log("Done in " + (Date.now() - start) + 'ms');
  } else {
    setTimeout(countDate); // schedule the new call
  }
}
countDate();

After that, the browser interface will be completely functional.

In case a new side task turns out at the time the engine is busy with the part 1, it will be queued and then run when the 1st part is completed.

The periodic returns to the event loop between the countDate executions provide time for the engine to react to other user actions.

The most interesting thing is that both of the options (with and without split) are comparable in speed.

For making them closer, you can initiate an improvement. For that purpose, it’s necessary to move the scheduling to the countDate() start, like this:

let t = 0;
let start = Date.now();
function countDate() {
  // move scheduling to the beginning
  if (t < 1e9 - 1e6) {
    setTimeout(countDate); // new call scheduling
  }
  do {
    t++;
  } while (t % 1e6 != 0);
  if (t == 1e9) {
    cosnole.log("Done in " + (Date.now() - start) + 'ms');
  }
}
countDate();

Running it will make you save time, as it takes significantly less time.

Progress Indication

The next advantage of splitting large tasks for browser scripts is that it’s possible to demonstrate progress indication.

Usually, the rendering takes place after the currently running code is complete.

On the one hand, it’s very convenient, as the function can create multiple elements, adding them one by one and changing their styles.

Let’s see a demo:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <div id="progressId"></div>
    <script>
      function count() {
        for(let i = 0; i < 1e6; i++) {
          i++;
          progressId.innerHTML = i;
        }
      }
      count();
    </script>
  </body>
</html>

While splitting a large task into pieces with setTimeout, the changes are painted out between them. So, the code below will look better:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <div id="divId"></div>
    <script>
      let i = 0;
      function count() {
        // do some hard work
        do {
          i++;
          divId.innerHTML = i;
        } while (i % 1e3 != 0);
        if(i < 1e7) {
          setTimeout(count);
        }
      }
      count();
    </script>
  </body>
</html>

Acting After the Event

Inside the event handler, you can delay some actions till the event has bubbled up and handled. It can be implemented by wrapping the code into zero delay setTimeout.

Let’s see an example where a custom menuOpen event is dispatched into setTimeout in a way that it takes place after the click event is handled completely:

menu.onclick = function () {
  // ...create a custom event with the data of the menu item that was clicked
  let customEvent = new CustomEvent("menuOpen", {
    bubbles: true
  });
  // dispatch the custom event asynchronously
  setTimeout(() => menu.dispatchEvent(customEvent));
};

You can learn more about it in chapter Dispatching custom events.

Macrotasks and Microtasks

Along with macrotasts, there exist microtasks that come exclusively from your code.

As a rule, promises create them. The execution of .then/catch/finally transforms into a microtask.

There exists a unique queueMicrotask(func) function, which queues func for running the microtask queue.

Right after each macrotask, the engine performs all the tasks from the queue of microtasks before running any other task or rendering something, and so on.

For example:

setTimeout(() => console.log("timeout"));
Promise.resolve()
  .then(() => console.log("promise"));
console.log("code");

In the picture below, you can see a richer event loop where the order is from top to bottom. In other words, the script comes first, then microtasks, rendering, and so on.

All the microtasks should be completed prior any other event handling or other actions.

It plays an essential role, because it assures that the application environment is similar between the microtasks.

In case you want to execute an asynchronous function before the changes are rendered or new events are handled, you should schedule it using queueMicrotask(func).

Let’s take a look at an example similar to the previous one. But, in this example, we use queueMicrotask(func) rather than setTimeout :

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <div id="divId"></div>
    <script>
      let t = 0;
      function count() {
        // do some hard work
        do {
          t++;
          divId.innerHTML = t;
        } while (t % 1e3 != 0);
        if(t < 1e6) {
          queueMicrotask(count);
        }
      }
      count();
    </script>
  </body>
</html>

Summary

In this chapter, we took a detailed look at the event loop algorithm in JavaScript.

The algorithm is the following:

  1. Dequeuing and running the oldest one from the macrotask queue.
  2. Implementing all the microtasks.
  3. If there are changes, rendering them.
  4. In case the macrotask queue is empty, waiting until a macrotask turns out.
  5. Going to step 1.

For scheduling a macrotask a zero delayed setTimeout(f) is used.

For scheduling a new microtask queueMicrotask(f) is used.




Do you find this helpful?

Related articles