Event Loop in Browsers: Execution and Task Prioritization
Most explanations of the event loop focus on a single JavaScript file. Authors often outline the rules that the JS engine follows. However, the actual execution environment of your code is complex. Understanding how to analyze it is crucial, and that's the motivation behind this discussion.
But, to simplify, let's start by understanding how the JS engine handles the event loop when running JavaScript code within a single file.
Later on, I'll guide you through navigating the obstacles smoothly.
While the topic may seem extensive, breaking it down into smaller stages can make it more accessible and easier to grasp.
Let's examine the following single JS file:
console.log(1);
setTimeout(() => console.log(2), 0);
new Promise((resolve, reject) => {
console.log(3);
resolve();
}).then(() => console.log(4));
function foo() {
console.log(5);
}
foo();
Let's break it down step by step:
The console.log(1)
statement is the first to be encountered in the code. It is pushed onto the stack, executed, and then popped off the stack. As a result, it logs 1 to the console.
1
Then we encounter setTimeout(() => console.log(2), 0)
. Similar to the previous code, it is pushed onto the stack. However, there's a slight difference. The browser recognizes it as a Web API, so it is moved to the Web API environment initially. It will stay there until the specified time has passed, and then it will be brought back to the callback queue.
By the way, some people claim that the actual wait time is 4ms, even though you set it to 0ms. However, things have changed nowadays, and different browsers have specific timings. Currently, in Chrome, it is reasonable to consider it as close to 0ms.
So, after 0ms, the function inside setTimeout is pushed into the task queue, waiting to be called.
By the way, now the task queue looks like:
task queue
--------------------------------
[()=>console.log(2)],
--------------------------------
Continuing, we encounter new Promise((resolve, reject) => {...})
, which creates a promise. It is pushed onto the stack, logs 3
to the console, and resolves immediately.
1
3
If you use
new Promise()
to create a promise object, the inner function will be called synchronously. In this code, it resolves immediately. Later, thethen(...)
method is called.and here's an interesting point: the function in the then method will be paused and pushed into the microtask queue.
So, () => console.log(4)
has been pushed into the microtask queue, waiting to be called.
Now, you can imagine how the microtask queue looks like.
microtask queue
--------------------------------
[() => console.log(4)]
--------------------------------
Continuing, we encounter a function declaration function foo() {...}
, and we call this function.
The function foo()
has been called, we push it onto the stack, log 5
, and then pop it out.
1
3
5
Now, the JS stack is empty! We can follow specific rules to pick something from our queues. This operation is called asynchronous processing.
Remember the queues that we mentioned before? They are as follows:
microtask queue
--------------------------------
[() => console.log(4)]
--------------------------------
task queue
--------------------------------
[()=>console.log(2)],
--------------------------------
The JS engine will prioritize tasks in the microtask queue, processing them until the queue is empty.
You've noticed that I emphasized the word "empty," and it's crucial. If a task in this queue creates a promise object, it will be pushed into the microtask queue again. If the count is limited, everything is fine. However, if it creates an infinite promise, the queue will seem like it's stacked indefinitely.
So, the () => console.log(4)
function has been pushed onto the stack, and it logs 4
.
1
3
5
4
Good, the microtask queue is empty now. Let's take a look at the task queue.
microtask queue
--------------------------------
Null
--------------------------------
task queue
--------------------------------
[()=>console.log(2)],
--------------------------------
The tasks in the task queue will be executed one at a time in a loop. Luckily, there is only one task here, so the JS engine pushes it onto the stack, logs 2
, and then pops it out.
1
3
5
4
2
Congratulations! We finally made it. We have now understood the basic event loop!
In a real browser environment, we need to analyze the event handling and network request mechanisms more precisely. The following code demonstrates a button that, when clicked by the user, triggers an onclick function. This function initiates a network request and outputs the result to the console once the request is complete.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Button Click Example</title>
</head>
<body>
<button id="fetchButton">Click Me</button>
<script>
document.getElementById('fetchButton').onclick = function() {
fetch('https://api.example.com/data') // Replace with actual API URL
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log(data); // Output the request result to the console
})
.catch(error => {
console.error('Request failed:', error);
});
};
</script>
</body>
</html>
When the user clicks the button, the browser pushes the corresponding event handler function into the macro task queue. At this point, the JavaScript execution stack is empty, and the microtask queue also has no content, so the next step will be to execute the content in the macro task queue.
Regarding how the event handler function is pushed into the macro task queue, I have gathered two perspectives:
- Each page has a unique rendering process with an event-triggering thread that shares the task queue memory with the JS thread, pushed by the event-triggering thread.
- The browser itself has a browser process that listens for clicks and informs the unique rendering process's IO thread via IPC, which then informs the task queue.
Next, the fetch function will be executed. Notably, fetch delegates the network request task to the browser's network process and immediately returns a Promise object. At this point, since the network request is not yet complete, the Promise is in a pending state, and thus the subsequent then callback will not be immediately pushed into the microtask queue.
About the network process:
In older versions of browsers, there was a network thread within the unique rendering process. However, recent updates by Google have introduced a standalone network process, and the rendering process no longer contains a network thread.
When the HTTP response headers are returned, the Promise returned by fetch is marked as fulfilled. Consequently, the callback function in the then method will be pushed into the microtask queue and will execute in the next event loop cycle.
When the execution time arrives, the callback function in then will be executed to handle the response. If the response is okay, the response.json() method will be called, passing its returned Promise to the next then.
According to the Promise mechanism, the outer then will only be marked as fulfilled when the Promise returned by response.json()
is fulfilled. Therefore, at this moment, the microtask queue is empty again, and the network process continues requesting data.
After some time, the network process finally successfully retrieves the remaining content from the HTTP link. At this point, the Promise returned by the following function can finally be marked as fulfilled:
then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
This causes the following function to be pushed into the microtask queue:
data => {
console.log(data); // Output the request result to the console
}
When the next event loop arrives, this microtask will be taken out and executed. Ultimately, the console will display the requested result data.
In this article, we explored the event loop within a single JavaScript file, delving into the browser processes, threads, and the intricate details of fetch
in the browser environment.
While this article may seem complex, with thoughtful understanding, you will surely gain clarity on the overall execution logic.
You can refer to some related articles in my blog; feel free to read through them. Browser Event Mechanisms: EventTarget vs EventEmitterEvent handling is a crucial task in frontend development. JavaScript offers various mechanisms for event handling, among which two common approaches are through the EventTarget interface in browsers and the EventEmitter class in Node.js. This article will delve into the principles, usage, and pros and cons of these two mechanisms.