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 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:
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:
It is worth noting that our program has not done anything yet at this point despite running two functions. When working with asynchronous code these are the nuances which can confuse developers, especially when you consider that the next line 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:
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 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) but 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.
Top comments (2)
Super interesting! This is the first time I've heard of the microtask queue and the job queue.
Thanks - glad you found it useful!