12
JavaScript Event Loop in Depth
Sometime back, I started learning about JavaScript and React JS internals. My goal is to understand the internals of JavaScript and frameworks. In my last article, I wrote about ReactJS internals. To understand more about React Fiber, we need to understand some preliminary JavaScript concept implementation. In the next three posts, I will be explaining in detail the following three concepts
- JavaScript Event Loop
- requestAnimationFrame and rendering cycle
- requestIdleCallBack
In this article, I will attempt to document my learning about Event Loop. This topic might be particularly interesting to people who want to learn how NodeJS and Deno internals work. The next two topics are interesting to me for understanding how React works internally (for code base understanding). So, let's dive deep.
JavaScript is a single-threaded language. It executes one method at a time. Following high-level components are integral to its execution environment -
- Call Stack
- Job Queues
- Rendering Step
- Microtasks
- Event Loops
Note: I have used runtime environment and execution environment interchangeably. At some points, I have used EcmaScript references. I am finding EcmaScript standards quite interesting. So, I thought to include those as well.
Like other languages like C, C++, etc., JavaScript uses a Call Stack to execute methods. Whenever it decides to execute a method, it wraps the method in a stack frame and pushes the frame into the Call Stack. For any eventual sub-method calls, it keeps on pushing sub-methods into the stack (LIFO order). Once a method is executed, the corresponding stack frame is popped. Call Stack is an integral part of JavaScript for synchronous programming.
test1();
function test1() {
console.log("Test 1 entry");
test2();
console.log("Test 1 exit");
}
function test2() {
console.log("Test 2 entry");
console.log("Test 2 exit")
}
For the above script, the result is as same as predicted:
Test 1 entry
Test 2 entry
Test 2 exit
Test 1 exit
But, JavaScript supports asynchronous programming. We can use setTimeout to execute something in the future like the below example. We could use the following code snippet that might trigger an alert and console.log() after 10s.
test1();
function test1() {
console.log("Test 1 entry");
setTimeout(test2, 10000);
console.log("Test 1 exit");
}
function test2() {
console.log("Test 2 entry");
alert("Test 2 execution");
}
As expected, the result is
Well, there was an alert as well. Following is the timeline of events
We can see the "Install Timer" at the bottom left. This is where the timer was installed to be fired after 10 seconds (10847ms).
It seems some thread is running in the background. Hold on! Did I not mention JavaScript is single-threaded? Let's see what could happen if JavaScript is multi-threaded. Suppose, in our hypothetical model, for the above JavaScript code execution, we are allowed to spawn a new thread for executing a single setTimeout. So, for one thousand setTimeout calls, we have a thousand threads. Each thread might attempt to change DOM as well independently. Now, if DOM is a shared resource for all the threads, multiple threads are bound to create chaos reading it at the same time. We would have spent most of our time debugging race conditions rather than building rich applications π. So, yes, JavaScript is single-threaded and it has served JavaScript and us well.
How come JavaScript schedule setTimeout callback after 10s then if there is no thread? The answer is Job or Task Queue. I am using both names interchangeably.
With Es6, the concept of Job Queue has been introduced. All the asynchronous tasks such as Mouse click, setTimeouts, etc., are pushed into the Job Queue for execution. JavaScript engine picks up tasks one by one and executes them using Call Stack (FIFO order).
The Event Loop. It runs in a loop throughout the life cycle of the app. It picks up tasks from the Task Queue and executes them with the help of Call Stack.
Note: My guess is the browser might maintain different queues for different kinds of event handling such as button clicks, mouse clicks, setTimeout calls, etc. Each browser can have different prioritization for each queue. It is just my guess. I am looking for ways to confirm that. I might write about that in the future.
You guessed it right - it runs in a loop.
The Browser engine can enqueue a task in the relevant queue. The Event Loop, in the next rotation, can dequeue the task and execute it.
<body>
<button id="demo">Trigger Alert</button>
</body>
<script>
document.getElementById("demo").addEventListener("click", () => {
console.log("Clicked");
while(true);
console.log("After loop is executed");
});
</script>
In the above example, the button click will enqueue the above synchronous script in Task Queue. In the next rotation, the Event Loop starts executing the task. Now, our script is stuck with infinite while loop. As a result, the Event Loop is stuck in the same position because of our infinite loop.
Let's look into another aspect of the JavaScript runtime environment - the Rendering Step. Let's take the following example
<body>
<p id="test_paragraph"> Test JS Hello</p>
<button onclick="changeParagraphColor()">Trigger Alert</button>
</body>
<script>
function changeParagraphColor() {
document.getElementById("test_paragraph").style.background = "red";
var top = 100, nested = 1000;
for(var i=0; i<top; i++) {
for(var j=0; j<nested; j++) {
console.log(i + ":" + j);
}
}
console.log("Changing color");
document.getElementById("test_paragraph").style.background = "blue";
}
</script>
In the browser, we can see the following output.
In the above example, the background color of the paragraph is changed to blue after the execution of the loop. The red background never takes effect. If I remove line 21, I can see the red paragraph, but after the execution of the script (including the loop). I expected to see first a paragraph with red background and then a blue. But I only saw, blue background.
Here, we are seeing the UI effect for the last line, but not the previous UI change. Why is that? That is because of the Rendering Step. The browser does not repaint the screen after the execution of each JavaScript code. It executes the above JavaScript code and collects all styling and UI changes. It applies final change (in this case line 21) in a single shot afterward, in the Rendering step. It is a step in the Event Loop that is executed independently. And initially, I thought the changes are happening so fast that my eyes could not catch them π€. I will dive into the Rendering step in my next post.
For now, I am associating Microtasks with JavaScript Promises. Promises are used to perform asynchronous operations in JavaScript. If you want to know more about Promises, visit this page for more details. The tasks executed inside Promise are called Microtasks. Following is an example
<body>
<button id="demo">Trigger Alert</button>
</body>
<script>
document.getElementById("demo").addEventListener("click", () => {
Promise.resolve().then(() => console.log("Micro Task 1"));
console.log("Task 1");
});
</script>
The above example will print the following output as expected. Microtask will be executed after all the synchronous tasks are executed.
Task 1
Micro Task 1
Let's look into another interesting example
<body>
<button id="demo">Trigger Alert</button>
</body>
<script>
document.getElementById("demo").addEventListener("click", () => {
Promise.resolve().then(() => console.log("Micro Task 1"));
console.log("Task 1");
});
document.getElementById("demo").addEventListener("click", () => {
Promise.resolve().then(() => console.log("Micro Task 2"));
console.log("Task 2");
});
</script>
Try guessing the output (take 5 seconds). If you guess the following output, it's perfectly alright! It is natural, intuitive, and we are human.
Task 1
Task 2
Micro Task 1
Micro Task 2
But the answer is
Task 1
Micro Task 1
Task 2
Micro Task 2
If you thought of the above answer, Congratulations! π π
Micro tasks are executed in two situations
- At the end of the synchronous script execution.
- If the Call Stack is empty.
For handling microtasks, JavaScript maintains another queue - Microtask Queue. For each call to Promise, an entry for each microtask is pushed into Microtask Queue. So, for the above example, the following will be the order
- Task 1 got pushed into Call Stack for synchronous execution. Also, Microtask 1 is enqueued in the Microtask Queue.
- After execution of Task 1, the Call Stack gets empty. So, it's the microtask time (condition 2 above)! If there were any other synchronous calls, Microtask 1 would have been picked up after execution of those.
- Microtask Queue is dequeued and the Microtask 1 gets executed.
- After step 3, the second handler gets called by Event Loop. The same thing repeats for the second microtask.
I know you might be thinking what am I talking about for this long. Nowadays, all browsers create a separate event loop for each browser tab. Collectively these are called Event Loops. At first, Chrome started doing this. Others followed soon.
I have seen one unresponsive page causing the entire browser to get stuck in Firefox's earlier version. I have never seen this issue with Chrome. Chrome has a separate JavaScript environment for each tab from early versions I believe. The browser might require more resources to do this. But I think, it is worth the better and richer experience.π
Let's assume, for the below three examples, target_page contains an infinite loop.
<a href="target_page" target="_blank">New Target Page</a>
For the earlier version browser, for the above scenario, the target_page and current page used to share the same Event Loop for all the JavaScript tasks. As a result, if the target_page is containing an infinite loop, both the current and target_page got stuck in an infinite loop as well. Both the pages used to become unresponsive. The workaround proposed
<a href="target_page" target="_blank" rel="noopener">New Tab</a>
Upon setting "rel=noopener", each page gets separate environment. It was introduced to reduce the impact of untrusted pages containing malicious JavaScript, which might affect the current page. Now, all the browsers have started implementing this behavior by default ( relevant MDN doc). I have tested with the latest Chrome. It is implemented there.
Also, for this example,
<a href="target_page">New Tab</a>
If you open target_page in a new tab (by right click on the mouse), both the pages share the same JavaScript runtime environment. So, if target_page has an infinite loop, both the pages get stuck.
The Event Loop is an interesting and critical component for understanding any JavaScript run time environments. I am building up my knowledge on React JS and Node JS internals upon this. If you have enjoyed the article, do give me thumbs up. In case of any questions, let me know in the comments.
Happy learning! π
- EcmaScript Standard - EcmaScript standard has documentation around how each component such as Job Queues, Call Stack should be implemented. I find these details particularly interesting.
12