Understanding the JavaScript Event Loop: Tasks, Promises, and Interviews
Written on
Today, we dive into a fascinating aspect of JavaScript: the Event Loop, microtasks, macrotasks, Promises, and prevalent interview questions in Software Engineering, particularly for frontend roles.
This subject may initially seem challenging, but once you grasp the inner workings of the code we frequently write, your skills will undoubtedly improve significantly.
What I Often Contemplate
Consider the following code:
console.log('Start');
setTimeout(() => {
console.log('Timeout');}, 0);
Promise.resolve().then(() => {
console.log('Promise');});
console.log('End');
Take a moment to reflect: do you understand the exact order of execution for the console.log statements above?
Initially, I used to think: - Hmm, a setTimeout(0) will probably execute right away. - A Promise that resolves immediately should behave similarly. - The two console.log statements should run first, while the setTimeout(0) and Promise resolve might depend on the JavaScript runtime, making it unpredictable.
A more practical example arises in React. When we want to access the DOM after React has re-rendered a component, we often use useEffect. Did you know we can also utilize setTimeout(0)?
import { useEffect, useState } from "react";
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("[useEffect] text:", document.getElementById("text").textContent);}, [count]);
return (
<div className="Main">
<div>
<button onClick={() => {
setCount(c => c + 1);
console.log("text:", document.getElementById("text").textContent);
setTimeout(() => {
console.log("text:", document.getElementById("text").textContent);});
}}>
Increase Count: <span id="text">{count}</span></button>
</div>
</div>
);
}
Do we ever ponder this?
If you code in Angular and encounter the ExpressionChangedAfterItHasBeenCheckedError, you might wrap it in setTimeout(0) without fully understanding why it resolves the issue.
Another example follows:
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 1e9; j++) {
i++;}
alert("Done in " + (Date.now() - start) + 'ms');
}
count();
In this example, we need to perform an intensive task — my demo involves a for loop running 1 billion times (1e9) and then alerting the runtime.
If you execute this code, you'll notice that the entire browser becomes unresponsive while the code runs. You can test this directly in your browser's console (F12 Inspect).
If 1e9 is too quick to notice, try 1e10.
The browser becoming unresponsive indicates that the Main thread (UI thread) is being blocked. Naturally, you should strive to avoid this situation in practice.
Imagine facing one of these questions in an interview, whether theoretical or as a bug-fixing task — can you handle it?
In this article, we will explore the origins of this issue in depth, so you will be well-prepared to address similar questions in an interview confidently.
Let's begin!
What is the Event Loop?
I initially intended to create my own explanation of the Event Loop in JS, but after numerous attempts, it still felt unclear. Fortunately, I recalled a YouTube video I recently watched.
I must say, after years of viewing various explanations about the Event Loop, this one stands out as possibly the most impressive I’ve encountered — it’s truly remarkable due to how intuitively the concept is presented.
Please take 10 minutes to watch it before we proceed; it's quite important:
Now that you've watched the video, let’s summarize:
JavaScript in the browser (and Node.js) executes based on the Event Loop. This is the 'secret weapon' that clarifies why JavaScript, despite being single-threaded, can manage asynchronous tasks (like setTimeout, setInterval, fetch, etc.) as if it had multiple threads (unlike other languages).
Essentially, the mechanism operates as follows: 1. Event Loop: A continuous loop that executes everything in the Call Stack. 2. Task Queue (Macrotask Queue): Holds callbacks for Macrotasks, such as setTimeout, setInterval, etc. 3. Microtask Queue: Contains callbacks for Microtasks, such as Promises and MutationObservers. 4. Macrotasks: Are only executed after both the Call Stack and Microtask Queue are empty. 5. Microtasks: Can trigger other microtasks, potentially leading to a situation where the Microtask Queue never empties, causing browser stutter or freezing. Macrotasks do not have this issue, as each Macrotask executes within one iteration of the Event Loop (similar to a loop iteration). 6. You can directly push a callback into the Microtask Queue using queueMicrotask, for example:
queueMicrotask(() => {
console.log(1)});
Moreover, here are some additional points not covered in the video: - With Workers, they run on a separate thread and have their own event loop. Typically, when we mention the Main Thread (or UI Thread), we refer to the primary thread used for handling UI. Most of our code executes on this thread, and if it gets blocked, the web page will freeze, resulting in a “Page unresponsive” error. - Each thread has its own Event Loop: for instance, the Main Thread has one Event Loop, and each Web Worker has its own Event Loop. Each browser tab represents a separate environment with its own Event Loop. - All Microtasks must be completed before the browser handles event handling/rendering or executes the next Microtask.
In summary, the basic execution order is:
Synchronous Code > Microtasks > Macrotasks
Common Interview Questions
Let’s examine some common questions that I’ve compiled to enhance our understanding of how the Event Loop functions.
Elementary Basics
Take a look at this code:
console.log(1);
setTimeout(function() {
console.log(2);}, 0);
Promise.resolve()
.then(function() {
console.log(3);})
.then(function() {
console.log(4);});
According to the theory above, we observe: - console.log(1): synchronous code - console.log(2): Macrotask because it is a callback from setTimeout - console.log(3) and console.log(4): Microtasks as they are callbacks from Promise
Thus, the execution order will be:
console.log(1);
console.log(3);
console.log(4);
console.log(2);
Let’s delve deeper to see what's in the Call Stack:
The Event Loop will execute in the following sequence: 1. Push console.log(1) onto the Call Stack — Execute it immediately, printing 1 to the console. 2. Push setTimeout onto the Call Stack — Execute it, pushing console.log(2) into the Macrotask queue. 3. Push Promise.resolve() onto the Call Stack — Execute it, pushing the two console.log statements from its .then handlers into the Microtask queue.
Note that the Call Stack operates from bottom to top, while the queues process from left to right.
At this point, the Call Stack is empty:
Subsequently, the Event Loop will take tasks from the Microtask queue and push them onto the Call Stack for execution:
And finally, the Macrotask queue:
When everything clears, we say that the “Event Loop completes one iteration” (one “tick” or one “iteration”).
So, what are the distinctions between Macrotasks and Microtasks?
Here’s the breakdown: - Microtasks: Callbacks from Promise, including then, catch, finally, and callbacks from MutationObserver. - Macrotasks: setTimeout, setInterval, script loading (<script ...>), event callbacks like onscroll, onclick, and various others.
I hope you've grasped the first example!
Advanced Level
In an interview, you’re less likely to encounter basic questions like the one above. Instead, you might face more intricate questions such as:
console.log("begins");
setTimeout(() => {
console.log("setTimeout 1");
Promise.resolve().then(() => {
console.log("promise 1");});
}, 0);
new Promise(function(resolve, reject) {
console.log("promise 2");
setTimeout(function() {
console.log("setTimeout 2");
resolve("resolve 1");
}, 0);
}).then((res) => {
console.log("dot then 1");
setTimeout(() => {
console.log(res);}, 0);
});
If posed with such questions in an interview, remain calm and remember the priority order:
Synchronous Code > Microtasks (Promise then/catch/finally) > Macrotasks (setTimeout/setInterval)
Let's analyze the code snippet above.
During the first iteration of the Event Loop, we have:
As usual, the steps are: 1. Push console.log("begins") onto the Call Stack — Execute immediately, printing "begins". 2. Push the first setTimeout callback onto the Call Stack — Move the setTimeout callback to the Macrotask queue. 3. Process the new Promise. The code inside new Promise runs synchronously just like the code outside. Therefore, console.log("promise 2") is pushed onto the Call Stack and executed immediately, printing "promise 2". Then, handle the setTimeout and push its callback into the Macrotask queue.
Note that in the diagram above, the Call Stack shows multiple items being pushed at once, but in reality, each item is pushed onto the stack and executed immediately.
At this point, the Call Stack is empty, and the Macrotask queue contains two items:
As usual, we continue with the execution order from left to right:
- Taking the first item from the Macrotask queue and executing it results in console.log("setTimeout 1") and Promise.resolve().
- Push the .then callback of the Promise.resolve() into the Microtask queue.
At this point, the Call Stack is empty again.
Before moving on, let’s repeat the mantra: Synchronous code > Microtasks (Promise then/catch/finally) > Macrotasks (setTimeout/setInterval)
Next, the Event Loop will take tasks from the Microtask queue and execute them:
After that, with the Call Stack empty and the Microtask queue also empty, the Event Loop will take the remaining item from the Macrotask queue and execute it:
Next, console.log(2) is executed, followed by resolve, and when resolve is called, the Event Loop pushes the callback of the Promise (.then) into the Microtask queue:
After that, it is taken from the Microtask queue and executed, resulting in console.log("dot then 1"). The callback of setTimeout is then pushed into the Macrotask queue. Since the Microtask queue is empty at this point, the Event Loop executes it immediately, and finally, console.log("resolve 1") is printed.
In summary, the output of running this code will be:
"begins";
"promise 2";
"setTimeout 1";
"promise 1";
"setTimeout 2";
"dot then 1";
"resolve 1";
There you have it; a problem like this can be challenging to understand, but the key is to explain it fluently during an interview so that the interviewer comprehends and is convinced.
It can be quite stressful.
Let's move a step further.
You might be well-prepared, but when you actually enter an interview, you could encounter a trickier question like this:
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");}
console.log("script start");
setTimeout(function() {
console.log("setTimeout");}, 0);
async1();
new Promise(function(resolve) {
console.log("promise1");
resolve();
}).then(function() {
console.log("promise2");});
console.log("script end");
Recite the mantra again: Synchronous code > Microtasks (Promise then/catch/finally) > Macrotasks (setTimeout/setInterval) Oh, the mantra doesn’t include await. Thank you and see you again.
Here, we need to learn an additional mantra: things after await are pushed into the microtask queue, specifically the console.log("async1 end") in the async1 function.
With this new mantra, the process is essentially the same as before. The execution order will be:
Explanation: 1. First, console.log("script start") is pushed onto the call stack and executed immediately, printing the result to the console. 2. Next, setTimeout is pushed onto the call stack, and its callback is added to the Macrotask queue. 3. The function async1() is pushed onto the call stack. 4. console.log("async1 start") inside the async1 function is executed. 5. The async2() function is then executed (called inside async1). 6. console.log("async2") is executed. 7. Since we are awaiting async2(), the remaining part of async1() will be pushed into the microtask queue, specifically console.log("async1 end") (the purple part in the image). 8. Next, new Promise is created, and console.log("promise1") is synchronous code, so it is executed immediately. 9. The resolve() inside new Promise is called, and its .then callback is added to the Microtask queue.
After performing all the above steps, the call stack is empty, and only the Microtask and Macrotask queues still have jobs remaining.
The execution order remains standard, with the Microtask queue first:
After processing the Microtask queue and having the Callstack empty, the jobs in the Macrotask queue will be executed.
Finally, the result we obtain will be:
"script start";
"async1 start";
"async2";
"promise1";
"script end";
"async1 end";
"promise2";
"setTimeout";
As with this, if your mind isn’t quick enough during an interview, you might be in trouble.
Important note: When you await a Promise, the Event loop will wait for that Promise to resolve before continuing.
Let's look at the following example:
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2 start");
await new Promise((resolve) => {
console.log("async2 promise");
setTimeout(() => {
console.log("async2 setTimeout");
resolve();
}, 0);
});
console.log("async2 end");
}
async1();
In the example above, console.log("async2 end") will only be executed when the resolve() is run because of the await new Promise.
Given this condition, try to predict the output when running the above code.
Improving Rendering Performance
P1. Break Down CPU-Intensive Tasks
For instance, during an interview, you might encounter a question like this: given the following code, the entire browser freezes when clicking Start. The task is to optimize rendering performance and prevent the browser from blocking.
<!DOCTYPE html>
<h1 id="count">Count: 0</h1>
<button onclick="start()">Start</button>
<div id="log">Log:</div>
<script>
"use strict";
let count = 0;
setInterval(() => {
document.getElementById('count').innerHTML = Count: ${++count};}, 1000)
function start() {
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 2e9; j++) {
i++;}
document.getElementById('log').innerHTML = "Log: Done in " + (Date.now() - start) + 'ms';
}
count();
}
</script>
When you run this code and click Start, you'll see that the JSFiddle browser becomes unresponsive, and the counter stops. Only after the for loop completes and the log is returned does the browser become responsive again, and the counter continues counting.
Let’s analyze and resolve this problem.
First, you can quickly identify the primary reason for the browser becoming unresponsive is the for loop running synchronously, iterating 2 billion times (2e9). This means the Event Loop will process this entire task before moving on to other tasks (e.g., rendering).
Check the image below:
Here, we can add another step to our mantra: The Event Loop will execute synchronous code first, then move to Microtasks, and only after completing Microtasks does it proceed to Rendering, followed by Macrotasks.
In this instance, the synchronous code (for loop) takes too long, resulting in Rendering being blocked.
The solution is to break the for loop into smaller chunks and run them across multiple iterations. Specifically: 1. Divide the loop into smaller “batches,” each batch running n times. Choose n wisely to avoid blocking the Event Loop. Here, n=1 million (1e6) seems reasonable. 2. During each batch, run as usual, and after completing each batch, give the browser some time before initiating the next batch. Utilize setTimeout for this, as Rendering occurs before Macrotasks. Thus, complete one batch ? pause for Rendering ? continue with the next batch. 3. Originally, looping 2 billion times in one go, we now divide it into 2 billion / 1 million = 2,000 batches, each with 1 million iterations.
With this strategy, let's modify the code:
<!DOCTYPE html>
<h1 id="count">Count: 0</h1>
<button onclick="start()">Start</button>
<div id="log">Log:</div>
<script>
"use strict";
let count = 0;
setInterval(() => {
document.getElementById('count').innerHTML = Count: ${++count};}, 1000);
function start() {
console.log('start');
let i = 0;
let start = Date.now();
function count() {
// do a piece of the heavy job (*)
do {
i++;} while (i % 1e6 !== 0);
if (i === 2e9) {
document.getElementById('log').innerHTML = "Log: Done in " + (Date.now() - start) + 'ms';} else {
setTimeout(count); // schedule the new call (**)}
}
count();
}
</script>
Here, we switch from for to do/while, but you can opt for for or while if preferred.
We utilize do/while to run until we complete 1 million (1e6) iterations, i.e., one "batch." After finishing each batch, we'll check if we've reached the total of 2 billion iterations. If we have, we’ll update the log and exit; if not, we’ll call the function recursively using setTimeout for the next batch. This way, each time we finish a batch, we're allowing the Event Loop some time to carry out other tasks (like Rendering).
When you click Start, you'll observe that the browser remains responsive, without lag or freezing, and the counter runs smoothly during the loop.
For my instance, it completed in 24 seconds.
P1.0 Performance Evaluation
After presenting the solution above, the interviewer might pose a follow-up question to assess whether the solution is genuinely optimized and if it can be further enhanced.
This could be a follow-up question, or the interviewer might provide you with the answer directly and ask you to optimize it.
Let's try moving the setTimeout from after the do/while loop to before the do/while loop and see how it affects performance.
<!DOCTYPE html>
<h1 id="count">Count: 0</h1>
<button onclick="start()">Start</button>
<div id="log">Log:</div>
<script>
"use strict";
let count = 0;
setInterval(() => {
document.getElementById('count').innerHTML = Count: ${++count};}, 1000);
function start() {
console.log("start");
let i = 0;
let start = Date.now();
function count() {
// move the scheduling to the beginning
if (i < 2e9 - 1e6) {
setTimeout(count); // schedule the new call}
do {
i++;} while (i % 1e6 != 0);
if (i == 2e9) {
document.getElementById('log').innerHTML = "Log: Done in " + (Date.now() - start) + 'ms';}
}
count();
}
</script>
Then we execute it again:
When you run it again, you'll notice the time dropped to 17 seconds.
What’s the reason for that? Does the order of execution truly matter? I thought callbacks from setTimeout would always execute after synchronous code.
In fact, it does make a difference. More about this can be found in the MDN Web Docs on setTimeout (check the “Nested timeouts” section).
If you repeatedly call setTimeout and nest it up to 5 times, from the 6th call onward, the browser introduces a slight delay of about 4ms. This means that when you call setTimeout, the browser will wait approximately 4ms before beginning execution.
Thus, calling setTimeout earlier can result in the overall example running faster.
P2. Displaying Progress
For the following code:
<!DOCTYPE html>
<body>
<h1 id="count">Count: 0</h1>
<button onclick="start()">Start</button>
<div id="progress"></div>
<script>
let count = 0;
setInterval(() => {
document.getElementById('count').innerHTML = Count: ${++count};}, 1000);
function start() {
console.log("start");
function count() {
for (let i = 1; i <= 5e6; i++) {
progress.innerHTML = i;}
}
count();
}
</script>
</body>
Task: Currently, whenever you click Start, the browser hangs. The requirement is to display the number of iterations incrementally without blocking the UI.
You may notice that with the current code, every time you click Start, the counter also freezes. After the for loop completes, it displays the number of iterations, and then the counter resumes running.
To keep the UI responsive and unblocked, the idea remains the same as before: break the large loop into multiple smaller “batches.” After each batch, update the UI with the results to allow the browser to perform rendering, then continue with the next batch.
<!DOCTYPE html>
<body>
<h1 id="count">Count: 0</h1>
<button onclick="start()">Start</button>
<div id="progress"></div>
<script>
let count = 0;
setInterval(() => {
document.getElementById('count').innerHTML = Count: ${++count};}, 1000);
function start() {
console.log("start");
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 5e3 != 0);
if (i < 5e6) {
setTimeout(count);}
}
count();
}
</script>
</body>
In the above, we still use do/while, with each “batch” running 1000 times (1e3). If we haven't completed the 5 million (5e6) iterations yet, we use setTimeout to run the next “batch”. The result when running will look like this:
As you can see, the progress is updated immediately and is much smoother.
Recap
Here’s a summary of what we’ve covered: - Event Loop Basics: Both browsers and NodeJS servers utilize an Event Loop to execute code. It's essentially an infinite loop that constantly checks for jobs to run. - Execution Order: The Event Loop processes jobs in the sequence of: synchronous code > Microtasks > Macrotasks. If await is used, everything subsequent to it is pushed to the Microtask queue. - Macrotask Execution: Macrotasks are executed only when both the Call Stack and Microtask queue are empty.
Feel free to revisit any part of this if you require more details!
Through this lesson, I hope I've equipped you with tools to tackle common questions in Software Engineering interviews.
Wishing you a fantastic weekend, and see you in the next lessons!