Event Loop, Task Queue and Microtask Queue
I highly recommend watching these two videos on event loop and queues since this chapter will be mostly a summary about the concepts mentioned in the videos.
- What the heck is the event loop anyway?
- JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue
Let's revisit the diagram in the last chapter.
Call Stack
If you don't know what a stack and a heap is, I will give you a basic rundown. In Javascript, complex data types such as objects and arrays are stored inside of heap while function calls and variables are put on the stack (call stack). When data is pushed onto the stack, it will be on top. And when data is popped from the stack, the top most data is removed. The size of the stack is limited and when too much data is pushed onto it, you get a stack overflow. On the other hand, heap is dynamic and more memory will be allocated if needed.
Let's take a deeper look at the call stack.
Suppose we have the following code: (taken from the video here)
console.log("One");
console.log("Two");
function logThree() {
console.log("Three");
}
function logThreeAndFour() {
logThree();
console.log("Four");
}
logThreeAndFour();
The call stack will have the following operations:
console.log("One")
runs and gets pushed onconsole.log("Two")
runs and gets pushed onlogThreeAndFour()
runs and gets pushed on, which runslogThree()
, which runsconsole.log("Three")
logThree()
finishes and gets popped offconsole.log("Four")
runslogThreeAndFour()
finishes and gets popped off
This should not be too confusing. When a function runs, it gets pushed onto the stack and when it finishes, it gets popped off. When one function is running and is on top of the stack, no other functions can run. This is why we need a separate mechanism to handle functions that take time to execute, such as setTimeout()
and fetch()
.
Event Loop and queues
When you run async functions such as setTimeout()
, these functions will actually get popped off from the stack and are handled by the Web API. Suppose you run the following function:
setTimeout(() => {
console.log("after timeout");
}, 1000);
The setTimeout()
will be pushed onto the stack and popped off after being constructed. Web API takes over and after 1 second, it pushes () => { console.log("after timeout"); }
onto the Task Queue. Think of Task Queue and Microtask Queue as queues that store codes that are pending to be run. They are separate from the call stack and won't block the code on the stack.
So the stack is empty and there are some codes in the Task Queue waiting to be run after one second. We need to somehow push the code in the queue back onto the stack. This is where event loop comes into play.
On the high level, event loop will keep checking the stack and queues. Whenever the stack is empty, it will take whatever is in the Microtask Queue first and then the Task Queue and put them onto the call stack one at a time. The action of taking from the Microtask Queue is called a Microtask checkpoint and it can happen in various stages. It takes priority over Task Queues so codes inside of Microtask Queue will be run before the codes inside of Task Queue.
The callback inside of setTimeout()
, setInterval()
, UI events (onClick
, onChange
), etc. will be pushed onto the Task Queue.
Callbacks inside of .then()
, .catch()
, queueMicroTask()
, new MutationObserver()
and code after await
will be pushed onto the Microtask Queue.
If you want to know which callback will be run first, it is usually better to test the code against setTimeout() and see for yourself. Or else you can go into the HTML specs which contains all the specifications for event loops and queues. (There are also other specific terms such as Task Sources and what a Task really is, but I won't go into the details of them)
Let's take a look at an example:
console.log("Script start");
fetch("https://abc.com/").then(() => {
console.log("Fetched");
});
console.log("Script end");
Here are the steps:
console.log("Script start")
runs and is pushed on and off the stackfetch()
runs and pushed onto the stack- Web API handles the fetch function and
fetch()
is popped off the stack console.log("Script end")
runs and is pushed on and off the stack (stack is empty now)- some time later,
fetch()
is completed and Web API pushes() => { console.log("Fetched"); }
onto the Microtask Queue - event loop checks that stack is empty, then it looks for codes inside of Microtask Queue
- the callback
console.log("Fetched")
then gets pushed onto the stack and runs
There is technically a main() function on the stack when a Javascript file runs. It's like the entry point and will exist on the stack until all lines of codes are executed. So in the example, the stack is only empty after "Script end" gets printed.
Let's take a look at another example:
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
queueMicrotask(() => {
console.log(3);
});
console.log(4);
The log will get printed in the order of 1, 4, 3, 2 since 2 gets pushed onto the Task Queue while 3 gets pushed onto the Microtask Queue.
The stack needs to be emptied first before the Microtask Queue, then the Task Queue.
Another important note is that there is more than just 1 Task Queue and they can have different priorities. For example, UI events (onClick
,onFocus
, ...) are put in a separate queue thansetTimeout()
and it allows UI callbacks to be processed first. But for the sake of simplicity, we won't dive into details.
Queue Starvation
You might notice that you can repeatedly push callbacks onto queues and "flood" the queue. Take a look at the following code.
const cb = queueMicroTask(() => cb());
cb();
The cb()
function will continuously push callbacks onto the Microtask Queue and tasks on other Task Queues will not get to run. This is called starvation and should be avoided.
requestAnimationFrame()
If you don't know what requestAnimationFrame()
is, it is a function that gets called everytime the browser produces a new frame, which is about once every 16ms on a 60Hz monitor. It is the preferred way to create animations over setInterval()
and setTimeout()
for many reasons and produces much smoother animations. (You can find more details here and here.)
One of the characteristic is that you can schedule new callbacks inside of a callback and you won't flood the queue like how queueMicroTask()
will, sooo how is the function handled? Well, this is where it gets a bit tricky. requestAnimationFrame()
does not get pushed onto a queue and is in fact stored somewhere else in an ordered map. The callback is invoked in the rendering step in the event loop called "update the rendering", which is where the website renders and produce a frame, but the rendering step only runs every once in a while and the timing is determined by the browser (depends on the refresh rate). Hence, it is not subjected to the prioritization system as we have seen with the Task Queues and Microtask Queue and it is better to think of it as a system of its own.
However, in the end of the day, all codes that needs to be run must be pushed onto the stack and parsed by V8. So if you have a lot of processing going on and always occupies the stack, it does not matter what kind of queues you are pusing the callback onto. They simply won't get the chance to run! This is also one of the reasons for janked animations and laggy scrolling.
Queues and Event Loop in Node JS works differently than the ones in browsers! You can learn more about here.
Reference: https://stackoverflow.com/questions/77008112/which-queue-is-associated-with-requestanimationframe