6.102
6.102 — Software Construction
Spring 2025

Reading 17 Addendum: More on the JavaScript Event Loop

Objectives

In this addendum…

  1. First, we’ll introduce how promise callbacks are handled a little differently than setTimeout callbacks, with a microtask queue.
  2. Then, we’ll show how Node implements additional features in the event loop, like the nextTick queue.

Promises and the Event Loop

Our current understanding of the event loop from this reading is solid. For the purposes of this class, that is what you need to know about how the JavaScript runtime system actually runs code and asynchronous callbacks. For those that are curious, it turns out that there are some additional details for how this all works, which can produce results that don’t align with our current understanding.

For example, consider this code:

/** Wait for `milliseconds` to pass before returning. */
function busyWait(milliseconds: number): void {
    const now = new Date().getTime(); // new Date().getTime() is always the current clock time in milliseconds
    const deadline = now + milliseconds;
    while (new Date().getTime() < deadline) {
        // do nothing, just wait until the clock reaches the deadline time
    }
}

let { promise, resolve, reject } = Promise.withResolvers();

setTimeout(() => {
  console.log("timer expired");
}, 0);

promise.then((result) => {
  console.log(result);
});

busyWait(5000);

resolve("promise resolved");

console.log("End of the code");

What do you expect to be printed in the console? Make a guess, then try it yourself! You can run the code on your own machine or here.

Interesting! If there were just one event queue, we would expect that the callback passed to setTimeout would be added to the event queue before the callback passed to the promise, especially because we are busy waiting for 5 seconds before we resolve the promise. So why don’t we see timer expired printed before promise resolved? Well, promise callbacks actually aren’t added to the main JavaScript event queue. They are added to another queue, called the microtask queue.

The microtask queue is just another queue like the main event queue (also called task queue or macrotask queue) that contains callbacks to run. However, these microtasks have a higher priority than the task queue:

The difference between the task queue and the microtask queue is simple but very important:

  • When a new iteration of the event loop begins, the runtime executes the next task from the task queue. Further tasks and tasks added to the queue after the start of the iteration will not run until the next iteration.
  • Whenever a task exits and the execution context stack is empty, all microtasks in the microtask queue are executed in turn. The difference is that execution of microtasks continues until the queue is empty—even if new ones are scheduled in the interim. In other words, microtasks can enqueue new microtasks and those new microtasks will execute before the next task begins to run, and before the end of the current event loop iteration.

This clears up the inconsistency above; the setTimeout callback is added to the task queue when the timer finishes, and the promise callback is added to the microtask queue when the promise resolves, which will be drained before we take the setTimeout callback off the task queue.

Just as we can use setTimeout to manually schedule tasks for the task queue, we can use queueMicrotask() to schedule microtasks on the microtask queue. However, you generally shouldn’t need to use queueMicrotask. Additionally, since the microtask queue is completely drained before the next iteration of the event loop, we must be careful if our microtasks ever queue other microtasks, since this recursive pattern could starve the event loop.

Why microtasks?

We’ve made sense of the example, but why do things work like this? What’s the point of having multiple queues? When would it be appropriate to use queueMicrotask? The MDN documentation asks this same question:

When is that useful?

The main reason to use microtasks is that: to ensure consistent ordering of tasks, even when results or data is available synchronously, but while simultaneously reducing the risk of user-discernible delays in operations.

What follows that quote are two good examples of why having a separate microtask queue and using queueMicrotask can be beneficial.

Animations

As another example, we’ll look at what happens with using microtasks vs macrotasks in animations. The animation below uses requestAnimationFrame to move a ball around the container and change its color. The callback function passed to requestAnimationFrame is called once right before the browser performs the next repaint. The code for this animation can be found in a <script> tag in the <head> of this webpage, so feel free to reference it by using the developer tools. The callback functions we pass to requestAnimationFrame change the position of the balls:

function animateMacrotask() {
    [currentLeftMacrotask, deltaLeftMacrotask] = updatePosition(currentLeftMacrotask, deltaLeftMacrotask);
    [currentTopMacrotask, deltaTopMacrotask] = updatePosition(currentTopMacrotask, deltaTopMacrotask);
    ballMacrotask.style.left = `${currentLeftMacrotask}%`;
    ballMacrotask.style.top = `${currentTopMacrotask}%`;
    requestIdMacrotask = requestAnimationFrame(animateMacrotask);
}

A typical animation would immediately start moving the ball once a control is clicked. To introduce longer delays, this example simulates the effect of having to make asynchronous requests in order to complete the button actions (e.g. a network response must be received or a file must be read when processing the user input). To simulate this, we store actions to take (e.g. face up, left, etc.) as a microtask and macrotask to perform. When you press “Start”, it will queue all those microtasks and macrotasks for execution by the event loop. We prepopulate the list of microtasks and macrotasks to run when the start button is clicked:

for (let i = 0; i < 10000; i++) {
    microtasks.push(faceLeftMicrotask, faceRightMicrotask, faceUpMicrotask, faceDownMicrotask);
    macrotasks.push(faceLeftMacrotask, faceRightMacrotask, faceUpMacrotask, faceDownMacrotask);
}

The only difference in how the accumulated microtasks and macrotasks are treated is when the start button is clicked:

startButton.addEventListener('click', () => {
    resetAnimation();
    // delay to let repaint occur and update ball position
    setTimeout(() => {
        microtasks.forEach((task) => queueMicrotask(task));
        macrotasks.forEach((task) => setTimeout(task, 0));
    }, 200);
});

The code uses queueMicrotask and setTimeout respectively, but there are multiple ways to accomplish this. The box on the left shows the animation that results from queueing the tasks as microtasks, and the box on the right show the animation that results from queueing the tasks as macrotasks.

Microtasks
Macrotasks

Why don’t both balls return to the same position? The microtasks are processed all in one batch before the next repaint, but the macrotasks aren’t necessarily processed in one batch before the next repaint. Rendering callbacks aren’t macrotasks on the task queue, they are handled differently. The HTML spec includes a section about the processing model, which describes how animation frame callbacks interact with macrotasks and microtasks.

After each macrotask is processed from a task queue, the rendering can be updated, which leads to a repaint and calling all the animation frame callbacks. The browser runtime may decide not to update the rendering, and the programmer doesn’t have direct control over this. Since microtasks are higher priority, they are all handled before the next repaint occurs. The macrotasks can interleave with the animation frame callbacks, which causes the difference in the animation. Due to the freedom given to the browser to determine when to update the rendering, the macrotask animation is nondeterministic 😱.

However, microtasks typically don’t need to be explicitly used or considered by the programmer. When fine grained control over event ordering is needed they can be quite useful, but otherwise they can stay tucked away in the back of the programmers mind.

The Event Loop in Node

The event loop in Node is somewhat more complicated. Node breaks the event loop into different phases, such as a phase to handle callbacks from I/O operations. Each phase has its own queue, and each queue is exhausted (or a callback limit is reached) before moving on to the next queue.

Where do microtasks come in? The microtask queue still exists and is drained after each operation from one of the phases completes. So now, we can think of Node has having multiple task (also called macrotask) queues, one for each phase, which are drained before moving on to the next task queue. The microtask queue works the same way as before, having a higher priority than all the other task queues, so that after each task in one of the task queues, the microtask queue is completely drained.

nextTick queue

Another distinct queue in Node is the nextTick queue, which can be thought to have the same priority as the microtask queue; both queues are completely drained before a task is taken off any task queue from an event loop phase, and the nextTick queue gets processed first. As with queueMicrotask, we can add events to the nextTick queue with process.nextTick().

Fun fact: Node internally uses the open source V8 JavaScript engine, which is also used in Chrome and other browsers. The microtask queue in Node is managed by V8, but the nextTick queue is separate from V8 and managed entirely by Node.

The microtask queue and nextTick queue have very similar semantics, so much so that Node generally recommends to use queueMicrotask over process.nextTick.

With all these queues, let’s put our understanding to the test:

reading exercises

Queue quarrel

What order will the logs appear when this code is run? Note that the nextTick queue is drained before the microtask queue is drained.

console.log(0);

const { promise, resolve, reject } = Promise.withResolvers();

promise.then((result) => {
    process.nextTick(() => {
        console.log(result);
    });
});

setTimeout(() => {
    resolve(2);
    console.log(1);
}, 0);

queueMicrotask(() => {
    console.log(3)
    process.nextTick(() => {
        console.log(4);
    });
    queueMicrotask(() => {
        console.log(5);
    });
});

process.nextTick(() => {
    console.log(6)
    queueMicrotask(() => {
        console.log(7);
    });
    process.nextTick(() => {
        console.log(8);
    });
});

console.log(9);

Enter the digits printed by console.log in the code above, in the order they are printed, separated by commas or spaces:

(missing explanation)

Since this is an optional addendum, this exercise is not counted in the classwork for class 17.

Summary

Wrapping up:

  • The JavaScript event loop has a macrotask queue and a microtask queue
  • The microtask queue is completely drained before each macrotask is taken of its queue and before the next browser repaint
    • macrotasks can interleave with browser repaints and requestAnimationFrame callbacks
  • Promise callbacks are added to the microtask queue
  • Node has different macrotask queues for different phases of the event loop, and a nextTick queue with the same priority as the microtask queue
  • queueMicrotask can be used to schedule callbacks on the microtask queue, likewise with process.nextTick and the nextTick queue.
    • These generally won’t need to be used unless fine grained control of event ordering is needed.

Here are various documentation sources that were used to inform this addendum and can be helpful to find additional information: