JavaScript job queue and microtasks

Last Updated:

When Promises were first introduced in ES6, they made the job of writing asynchronous code easier. Callback hell was replaced with simpler constructs that allowed developers to more easily handle asynchronous tasks. The key to understanding promises is knowing how the job queue (also known as the microtasks queue) works in JavaScript.

We will start by looking at some code:

function firstFunction() {
  thirdFunction()

  const firstResponse = Promise.resolve('1st Promise');
  const secondResponse = Promise.resolve('2nd Promise');

  setTimeout(() => {
    firstResponse.then(res=> {
      console.log(res);
    })
  })

  secondResponse.then(res=> {
    console.log(res);
  })
}

function thirdFunction() {
  const thirdResponse = Promise.resolve('3rd Promise');
  const fourthResponse = Promise.resolve('4th Promise');

  queueMicrotask(() => {
    console.log('Hello from the microtask queue')
  })

  thirdResponse.then(res=> {
    console.log(res);
  })

  setTimeout(() => {
    fourthResponse.then(res=> {
      console.log(res);
    })
  })
}

function secondFunction() {
  let i = 0;
  let start = Date.now();

  for (let j = 0; j < 5.e9; j++) {
    i++;
  }
  console.log("Loop done in " + (Date.now() - start) + 'ms');
}

setTimeout(() => {
  console.log('first timeout')
});

firstFunction()
secondFunction()
console.log('first console log')

In what order should we expect the logs to appear?

Enter the event loop

It maybe surprising to learn that the ECMAScript specification does not mention the event loop. Instead, the event loop refers to the way in which code is processed by a browser’s JavaScript engine. JavaScript runs on a single-threaded model so only one task can be processed at any moment in time. This obviously can lead to complications. What happens if a mouseover event is triggered just before a timer started by setTimeout expires? Or if you fire a network request and the response comes in the middle of the browser re-rendering the UI?

The diagram below shows the different parts of the browser that work together to manage this asyncronicity.

The internals of a typical web browser

The event loop performs its work in iterations or “ticks”. JavaScript code is executed in a run-to-completion manner (the current task is always finished before the next task is executed), so each time a task finishes, the event loop checks if it is returning control to other code. If it is not, it runs all of the tasks in the job queue and then runs the tasks in the task queue. We can better illustrate this by applying it to our example code.

// ... firstFunction, secondFunction and thirdFunction declarations have been omitted for brevity

setTimeout(() => {
  console.log('first timeout')
});

firstFunction()
secondFunction()
console.log('first console log')

When firstFunction is executed, the browser’s internal state is:

Alt Text

If setTimeout is called without a specified duration, it defaults to 0 milliseconds. setTimeout itself is a browser API so it does not appear in the call stack but the callback it returns is placed into the task queue ready to be called during a future event loop iteration.

The first thing firstFunction does is call thirdFunction, which looks like:

function thirdFunction() {
  const thirdResponse = Promise.resolve('3rd Promise');
  const fourthResponse = Promise.resolve('4th Promise');

  queueMicrotask(() => {
    console.log('Hello from the microtask queue')
  })

  thirdResponse.then(res=> {
    console.log(res);
  })

  setTimeout(() => {
    fourthResponse.then(res=> {
      console.log(res);
    })
  })
}

This is where things get interesting. In the code above we resolve two promises and assign their resolved values. Using the then method on each promise, we specify the function which should run once it settles. A settled promise is a promise which has moved from pending (when it is performing an underlying process such as fetching data) to either fulfilled (success) or rejected (error). When it settles, it queues a microtask for its callback. queueMicrotask is self-explanatory. It is a more direct way of queuing a microtask.

Once thirdFunction has finished executing, control is handed back to firstFunction which also finishes running its code. After this the browser’s internal state is:

Alt Text

It is worth noting that our program has not done anything yet at this point despite running two functions. When working with asychronous code these are the nuances which can confuse developers, especially when you consider that the next line of our code, secondFunction, mimics the behaviour of blocking code by running a loop which last for a few seconds. Despite having six callbacks queued, the console.log statement in secondFunction is the first thing printed to the console and it is followed by the last line of the script, which is another log statement.

At this stage the event loop reaches the end of its current iteration, so it looks to the job queue and runs the callbacks there in a first-in-first-out manner. It is possible for the code in the job queue to schedule more callbacks. However, these will not be deferred until future iterations but will instead run in the current iteration, meaning it is possible to starve your program by creating an endless loop of job queue callbacks. At the end of the first iteration, the following will have been logged to the console:

Loop done in 5672ms
first console log
Hello from the microtask queue
3rd Promise
2nd Promise

And the browser’s internal state is:

Alt Text

You will notice that the callbacks for both the first and fourth promises were never put into the job queue. This is because instead of calling then on them directly, we put them in the callback for a setTimeout function. So when the event loop begins its second iteration, it will first look at the task queue. The setTimeout callbacks for our promises will each queue another callback for the job queue which will be run at the end of current iteration. This means when our program has finished running, the following is the order in which things are logged to the console. If any of the code in the second iteration queued more stuff for the task queue, it would not be run until a future iteration.

Loop done in 5672ms
first console log
Hello from the microtask queue
3rd Promise
2nd Promise
first timeout
4th Promise
1st Promise

Summary

Whilst the explanation above is not an exhaustive look at the job queue (for example, we never covered how it is used by the MutationObserver API) I hope it has improved your understanding of the job queue in relation to promises. When writing code it is easy to forget that its execution order is not always straightforward, especially when it runs in a browser environment where other tasks outside your control could happen.