· By Wataru Ito · Coding · 7 min read
JavaScript Event Loop
How the call stack, task queue, and microtask queue work together in the browser, and when the browser updates the screen.
When I started to tinker with JavaScript code to solve various problems, I learned to use setTimeout, setInterval, then Promise. I also learned that some libraries use requestAnimationFrame, and with some examples, I used it. At first, I was confused. Which callbacks are firing when, and why? Over time, I felt I “got” it and saw the pattern, except I did not. Then I had to learn this concept: The Event Loop.
This is a note of what I’ve learned about the Event Loop so far. I’m talking about JavaScript in the browser here, on the main thread.
The Call Stack
The browser runs one piece of JavaScript at a time on the main thread. During execution, it keeps track of function calls on the call stack. If function A calls function B, B goes on top of A. After B finishes, execution returns to A.
A callback waiting to run is not on this stack yet. Calling setTimeout registers a timer and lets the current code continue. Even with a delay of 0, the callback has to wait for the current synchronous code to finish. Queuing a microtask does not interrupt that code either.
Two To Do Lists
I find it helpful to imagine two to-do lists: the task queue (also called the macrotask queue) and the microtask queue. The browser uses the event loop to decide when to run the waiting work.
These are queues, so for the examples here, we take the oldest waiting item first. The call stack works in the opposite direction: the last function we entered has to finish before we return to the one below it.
Timer callbacks run as tasks. Promise’s .then() callbacks and callbacks we pass to queueMicrotask run as microtasks. The browser has more than one task queue, but we can use one task queue in this model to follow our timer examples. requestAnimationFrame has a separate place in the rendering process, which I’ll come back to below.
For these examples, we finish the current script, then run microtasks until the microtask queue is empty. After each timer task, we drain the microtask queue again before running the next task.
Let’s consider this:
setTimeout(() => {
console.log("1");
}, 0);
console.log("2");
setTimeout(() => {
console.log("3");
}, 0);
Promise.resolve().then(() => {
console.log("4");
});
console.log("5");
Promise.resolve().then(() => {
console.log("6");
});During the initial script, the following happens:
- register the first
setTimeouttimer - output “2”
- register the second
setTimeouttimer - queue the first Promise
.then()callback in the microtask queue - output “5”
- queue the second Promise
.then()callback in the microtask queue; the script finishes, so we start draining that queue - output “4”
- output “6”; the microtask queue is empty
- run the first timer task when it is ready, and output “1”; check the microtask queue, which is empty
- run the second timer task when it is ready, and output “3”
The output is 2, 5, 4, 6, 1, 3.
With setTimeout, we register a timer first. Once its waiting time has passed, the browser queues a task to run the callback. A delay of 0 does not mean “run now,” and a delay of 100 does not guarantee execution at 100ms. Other work can make it wait longer.
Both Promises in this example are fulfilled, so calling .then() queues their callbacks as microtasks. With a pending Promise, we register the callbacks and wait for the Promise to settle before queuing the matching callback.
Let’s replace Promise’s .then() with queueMicrotask, which adds a callback to the microtask queue:
setTimeout(() => {
console.log("1");
}, 0);
console.log("2");
setTimeout(() => {
console.log("3");
}, 0);
queueMicrotask(() => {
console.log("4");
});
console.log("5");
queueMicrotask(() => {
console.log("6");
});The output stays the same: 2, 5, 4, 6, 1, 3.
Then modify the code a little, and see what’d happen:
setTimeout(() => {
console.log("1");
queueMicrotask(() => {
console.log("2");
queueMicrotask(() => {
console.log("3");
});
queueMicrotask(() => {
console.log("4");
});
});
}, 0);
console.log("5");
setTimeout(() => {
console.log("6");
}, 0);
queueMicrotask(() => {
console.log("7");
});
console.log("8");
queueMicrotask(() => {
console.log("9");
});Following this example from the initial script:
- register the first
setTimeouttimer - output “5”
- register the second
setTimeouttimer - queue the first
queueMicrotaskcallback in the microtask queue - output “8”
- queue the second
queueMicrotaskcallback; the script finishes, so we start draining the microtask queue - run the first microtask, output “7”
- run the second microtask, output “9”; the microtask queue is empty
- run the first timer task when it is ready, output “1”, and add one callback to the microtask queue; after the timer callback returns, start draining the microtask queue
- run that microtask, output “2”, and add two more callbacks to the microtask queue
- keep draining the same queue, including the two callbacks we added
- run the next microtask, output “3”
- run the next microtask, output “4”; the microtask queue is empty
- run the second timer task, output “6”
The output is 5, 8, 7, 9, 1, 2, 3, 4, 6.
We can queue more microtasks inside a microtask. The browser adds them to the end of the microtask queue, and runs them before taking the next task.
For these examples, I keep these steps in mind:
- finish the current synchronous code
- run the microtasks until the queue is empty, including ones added during that work
- run the next ready task, then drain the microtask queue again
I think of microtasks as having a “VIP pass” ahead of the next task. They still have to wait for the current synchronous code to finish. And if we keep adding microtasks without letting the queue become empty, we can block the next task and the browser’s chance to draw the screen. Moving a long calculation into queueMicrotask won’t make it run on another thread.
I know this article is a bit obsessive and redundant, but following the callbacks one by one helped me understand the order.
Updating the Screen
The part I was missing was when the browser draws what we changed. Updating the DOM does not mean the user sees the change at that moment. The browser needs a chance to update the screen after our code gives back control.
The browser does not draw after each task. It chooses rendering opportunities based on things like the display’s refresh rate and whether the page is visible. So we should not read the steps above as “one task, microtasks, one frame” on each turn.
With requestAnimationFrame, we ask the browser to call our function before an upcoming repaint. The browser runs this callback as part of its rendering update. We can use it to change something we want to show in that frame.
For example, run this on a page with a body:
const box = document.createElement("div");
box.style.width = "80px";
box.style.height = "80px";
box.style.backgroundColor = "tomato";
document.body.appendChild(box);
console.log("1: box added");
queueMicrotask(() => {
console.log("2: microtask");
});
requestAnimationFrame(() => {
box.style.transform = "translateX(100px)";
console.log("3: animation frame callback");
});On a visible page, the logs will be 1, 2, 3. We add the box during the script, run the microtask after the script finishes, and move the box in the animation frame callback before the browser paints it. You may never see the box in its original position. This example makes one change; it does not animate the box across the screen.
requestAnimationFrame calls the callback once. For an animation, we request another frame from inside the callback and update the position over time. The browser usually pauses these callbacks in background tabs, so we should not use them as a timer for work that must continue there.
Doing heavy work inside this callback delays the frame too. And awaiting an already fulfilled Promise does not guarantee a chance to paint: we continue in a microtask. This helped me understand why changing a loading message and then doing a long calculation can leave the screen looking unchanged until the calculation finishes.
I used AI to help check the accuracy of the explanations in this article.