JavaScript setTimeout() An In Depth Look, part 2

JavaScript: Promise.resolve() vs. setTimeout()

Note: The animated visualizations in this post were created with https://jsv9000.app/. I encourage you to play around with this web app to learn more about the Event Loop with a hands-on approach.

This is the second in a series of two posts about JavaScript’s execution model. The first post focuses on the Call Stack and Event Loop. This post builds on that foundation by discussing how Promises fit into the picture.

In the previous post, we learned about JavaScript’s Call Stack and Event Loop. We worked through an example that used setTimeout() to break a long-running synchronous function into a series of short tasks. But why did we choose setTimeout()? Could we have used Promise.resolve() instead? After all, Promises are supposed to be asynchronous, and all async JavaScript works by enqueuing tasks in the Event Loop.

Let’s try it!

setTimeout() vs. Promise.resolve()

We’ll use the webpage we created in part 1 to test our theory.

This was the final version of our computePrimes() function:

function computePrimes(onPrime, startAt = 2) {
let currNum = startAt;
while (true) {
if (isPrime(currNum)) onPrime(currNum);
currNum += 1;
if (currNum % 500 === 0) break;
}
setTimeout(() => computePrimes(onPrime, currNum), 0); // Magic‽
}

Let’s rewrite it to use Promise.resolve()instead of setTimeout():
function computePrimes(onPrime, startAt = 2) {
let currNum = startAt;
while (true) {
if (isPrime(currNum)) onPrime(currNum);
currNum += 1;
if (currNum % 500 === 0) break;
}
// Let's try Promise.resolve() instead of setTimeout()
Promise.resolve().then(() => computePrimes(onPrime, currNum));
}

Now, open the webpage in your browser and click the Start Computing Primes button.

Webpage after running for a short time using Promise.resolve()

It doesn’t work! Maybe Promise.resolve() isn’t actually asynchronous after all? Let’s check and see:

function logA() { console.log('A'); }
function logB() { console.log('B'); }
function logC() { console.log('C'); }

logA();
Promise.resolve().then(logB);
logC();

// => A C B

Try running this yourself. You’ll see that B is always logged last. This is the same behavior we saw in part 1 from setTimeout(). So, we’ve confirmed that Promise.resolve() is, in fact, asynchronous. It’s enqueuing tasks like setTimeout() does.

But when we try to break up our computePrimes() function using Promise.resolve() instead of setTimeout(), it doesn’t work! Why‽

Clearly, setTimeout() and Promise.resolve() are similar, but also different in some fundamental way. Let’s try tweaking our previous script to get a better handle on how they each behave:

function logA() { console.log('A') }
function logB() { console.log('B') }
function logC() { console.log('C') }
function logD() { console.log('D') }

logA();
setTimeout(logB, 0);
Promise.resolve().then(logC);
logD();

// => A D C B

The two synchronous calls (logA() and logD()) are executed first, as expected. But the two async calls we made with setTimeout() (logB()) and Promise.resolve() (logC()) are logged out of order. The task enqueued by Promise.resolve() is always executed before the task enqueued by setTimeout().

This doesn’t make sense, because the Event Loop processes tasks in the Task Queue in FIFO order. And since setTimeout() ran first in our script, we’d expect its task to be executed first. But it clearly isn’t.

The understanding we gained in part 1 about the Call Stack and Event Loop can’t explain what is going on here. In order to understand why setTimeout() and Promise.resolve() behave differently, we’ll have to talk about Microtasks. But before we get to that, we need to flesh out our understanding of Tasks and the Task Queue a bit.

Tasks

We introduced the Task Queue as part of the Event Loop in part 1. It was presented as a single FIFO queue of Tasks. However, this picture is incomplete. In fact, the Event Loop contains several Task Queues.

The JavaScript language specification allows JS engines to have as many queues as they want. But there are 2 required queues:

  • Script Task Queue – this queue contains tasks that validate and evaluate JavaScript source code. We won’t discuss this queue further in this post.
  • Promise Task Queue – this queue contains tasks that were enqueued after a Promise was resolved or rejected. For example, you can place tasks in this queue using Promise.resolve().then(taskFn).

JS engines usually have additional queues as well. For example, a browser might have a third queue for DOM events, and a fourth for timer callbacks.

The JavaScript specification doesn’t dictate the order in which these queues are to be serviced. This is left up to the designers of the JavaScript engine to decide. An engine might choose to handle all the events in its timer queue first, and only move onto the DOM event queue when the timer queue is empty. Or, the engine might interweave events from both queues.

It is also left to the engine designers to decide what happens when all event queues are empty. An engine might choose to exit (like NodeJS) or continue running and wait for some outside source to enqueue a new event (like web browsers).

Let’s model our updated version of the Event Loop in JS code:

while (EventLoop.waitForTask()) {
 const taskQueue = EventLoop.selectTaskQueue();
 if (taskQueue.hasNextTask()) {
   taskQueue.processNextTask();
 }

 rerender();
}

Let’s also update our Event Loop diagram from part 1 to include additional Task Queues:

The JavaScript Event Loop with Multiple Task Queues

The JavaScript Event Loop with Multiple Task Queues

Something Smells Fishy 🐟

The HTML specification outlines a 9 step processing model for the Event Loop in browsers:

An event loop must continually run through the following steps for as long as it exists:

    • Select the oldest task on one of the event loop’s task queues.
    • Set the event loop’s currently running task to the task selected in the previous step.
    • Run the selected task.
    • Set the event loop’s currently running task back to null.
    • Remove the task that was run in step 3 from its task queue.
    • Perform a microtask checkpoint.
    • Update the rendering.
    • …Omitted (this step only applies for Web Workers)…
    • Return to the first step of the event loop.

This is important, because according to this model, the browser should re-render in-between processing tasks.

Something doesn’t add up here. If Promise.resolve() and setTimeout() both enqueue tasks, then they should be equivalent as far as our computePrimes() function is concerned: computePrimes() should be able to break up its prime computations into a series of tasks in either the Promise or Timer Task Queues. And in between running these tasks, the Event Loop should allow the browser to re-render (step 7). But we’ve shown that this only works for the Timer Task Queue. Based on our experiment earlier, the browser does not re-render in-between processing tasks from the Promise Task Queue.

So what gives?

A Willful Violation

This discrepancy is due to something the HTML spec calls Microtasks. But Microtasks aren’t mentioned anywhere in the JavaScript spec. So then, where do they come from? The HTML spec has a section that talks about this (note that the term “job” in this excerpt is what we’ve been calling a “task”):

The JavaScript specification defines the JavaScript job and job queue abstractions in order to specify certain invariants about how promise operations execute with a clean JavaScript execution context stack and in a certain order. However, as of the time of this writing the definition of EnqueueJob in that specification are [sic] not sufficiently flexible to integrate with HTML as a host environment.

NOTE: This is not strictly true. It is in fact possible, by taking liberal advantage of the many “implementation defined” sections of the algorithm, to contort it to our purposes. However, the end result is a mass of messy indirection and workarounds that essentially bypasses the job queue infrastructure entirely, albeit in a way that is technically sanctioned within the bounds of implementation-defined behavior. We do not take this path, and instead introduce the following willful violation.

As such, user agents must instead use the following definition in place of that in the JavaScript specification. These ensure that the promise jobs enqueued by the JavaScript specification are properly integrated into the user agent’s event loops.

That was a lot of words. The HTML spec is basically saying that a particular section of the JS spec makes it hard for the HTML spec to do its job. So instead of trying to work around that section, the HTML spec just came up with its own version.

Interesting! The HTML spec intentionally violates the JavaScript spec. This violation is where Microtasks come from.

As a result of this violation, the picture we formed earlier in this post about the Promise Task Queue isn’t really correct . None of the major JS engines have a Promise Task Queue. Instead, they have a Microtask Queue. Tasks that would have gone to the Promise Task Queue go here instead, and are called Microtasks.

Microtasks

Microtasks are a lot like Tasks. They are synchronous blocks of code (think of them as Function objects) that have exclusive access to the Call Stack while running. And just like Tasks, Microtasks are able to enqueue additional Microtasks or Tasks to be run next.

The only difference between Microtasks and Tasks is where they are stored, and when they are processed.

  • Tasks, as we know, are stored in Task Queues. But Microtasks are stored in the Microtask Queue (there’s only one of these).
  • Tasks are processed in a loop, and rendering is performed in-between tasks. But the Microtask queue is emptied out after a task completes, and before re-rendering occurs.

Let’s add the microtask queue loop to our Event Loop model:

dir="ltr">while (EventLoop.waitForTask()) {
const taskQueue = EventLoop.selectTaskQueue();
if (taskQueue.hasNextTask()) {
taskQueue.processNextTask();
}


const microtaskQueue = EventLoop.microTaskQueue;
while (microtaskQueue.hasNextMicrotask()) {
microtaskQueue.processNextMicrotask();
}rerender();
}

And we’ll update our diagram as well:

The JavaScript Event Loop with Multiple Task Queues

Why Promise.resolve() Freezes Our Webpage

Let’s return to our example. It works great if we break apart our prime calculations using setTimeout(). But if we try to use Promise.resolve() instead, the webpage freezes again.

This happens because Promise.resolve() doesn’t enqueue a Task – it enqueues a Microtask. And as we saw above, the Event Loop doesn’t re-render until the Microtask Queue is empty. As a result, if the Microtask Queue is never empty, the browser will never rerender.

This is exactly what is causing our example webpage to freeze! Right before the computePrimes() function returns, it enqueues another Microtask to compute the next batch of primes. This prevents the Microtask Queue from ever being fully emptied by the Event Loop.

The reason setTimeout() works fine is because it enqueues Tasks, not Microtasks. And as explained above, the browser is able to re-render in-between processing Tasks (but not in-between Microtasks).

Let’s visualize all of this.

Visualization of the Microtask Queue. Created with https://jsv9000.app/

Notice how the Event Loop gets stuck at the Run all Microtasks step. No matter how long the script runs, it will never move onto the rerender step.

Conclusion

Before ES6, JavaScript’s Event Loop model was relatively simple and played well with the HTML specification. But with ES6 came Promises. And with Promises came additional complexity for JavaScript’s Event Loop. The JS spec handles Promises with just another Task Queue. But the HTML spec violates the JS spec by handling Promises with a new structure: the Microtask Queue. This is how all major JavaScript engines handle Promises today.

Two obvious ways to launch an asynchronous operation in JavaScript are setTimeout(taskFn, 0) and Promise.resolve().then(microtaskFn). If you only have a basic understanding of JavaScript and its Event Loop, these approaches might appear to be equivalent. But in reality, they operate differently in some important ways due to Microtasks.

I hope this post has helped you better understand how Promises interact with JavaScript’s execution model. Thanks for reading!

-Andrew Dillon, Software Engineer, QDivision

For more tech blogs from QDivision, go here.

Additional Reading and Watching

Primary Sources

Secondary Sources

 

Comments
pingbacks / trackbacks

Leave a Comment

Contact Us

We're not around right now. But you can send us an email and we'll get back to you, asap.

Not readable? Change text. captcha txt