Understanding the browser’s Event Loop for building high-performance web applications. Part 2.
A deep dive into the Event Loop: task priorities, dealing with long tasks, using the browser idle time, scheduling microtasks, counting the number of event loops on a page.
In a previous blog post, we looked at the Event Loop algorithm from a bird’s eye view. In this article, we’ll take a deeper look at the mechanisms of the Event Loop and how to use its features to write efficient code.
Deep into the task.
As we said before, a task is a piece of work that the browser needs to do.
Each task has an additional property —
source. Which defines the type of task.
For example, keyboard, mouse, and other events sent in response to user interactions have user interaction sources. Tasks related to DOM manipulation, such as inserting an element into a document, have DOM manipulation task sources. There are others: networking, navigation, etc.
The browser can choose tasks with which source to give preference. To do this, browsers implement not one, but several queues. For example, a browser can make a separate queue for tasks with a user interaction source and give preference to it, thus increasing the performance of user interaction operations. (All modern browsers usually do this.)
It’s good news. Even if you have quite a few tasks, the browser will prioritize the important ones, so performance won’t be affected.
The bad news is you can still mess up the user experience if your tasks are too long. Executing a task blocks all other actions of this thread — for example, responding to user actions such as mouse clicks or scrolls. Therefore, long tasks have a negative impact on performance and should be broken down into smaller ones.
To find out such long tasks, use the Chrome Dev tools.
Go to Chrome
Dev tools ->
Performance tab -> click
Record -> perform actions on the page that use the code you want to test (e.g. page reload or a button click) -> stop recording and analyze the result.
By zooming in, you can distinguish between individual tasks. For example, this is what a single call to
setInterval looks like:
Chrome marks long tasks with a red stroke and a pop-up, which indicates the duration of the task:
When long tasks are found, you need to break them into small ones. How to do it?
We have refactored 3 functions that were taking too long to execute. Which function will now give the browser time to respond to user input?
If your function does a lot of work, even if it is broken into subfunctions, it’s all a single task, during which the browser cannot be interrupted. Example:
However, this kind of refactoring should not be underestimated. Breaking up a large function is still productive. The browser makes useful optimizations with our code. They are easier to implement on small functions. In addition, sometimes optimization is followed by deoptimization. Optimization and deoptimization can occur several times with the same function. And the larger the function, the more resources this process will require.
Promise callback is queued for microtasks.
Microtasks are executed immediately after the execution of the current task, which means that the browser will not be able to interrupt to process the user action.
A long task can be broken into smaller ones using
setTimeout. This way, in between chunks of one big task, the browser can do important work, such as responding to user input.
However, this method is not very convenient when we need to break up a long loop.
On my computer, this script runs for 520ms:
In such a case, we can use a combination of
Now, instead of one long task, we have many small ones between which the browser can perform other important tasks:
How does this code work?
pause function is called:
- The code inside the
Promiseis executed synchronously, causing the
setTimeoutcallback to be put on the task queue.
pausefunction is popped off the stack and execution continues from where it was called.
- At this point, the browser encounters the
awaitkeyword. It pauses the execution of the function, the rest of the function will be executed as a microtask after the
- Now the browser returns to the line where the
foofunction was called from. Since this function call does not have an
await, the browser executes the rest of the code.
- When the task is completed, the browser picks up the next one. This is when the browser can do important work such as responding to a click event.
- When there is no more important work left, the browser will execute our
- Executing the
setTimeoutcallback will resolve the
promise, which will cause the browser to jump back to executing the
Using Scheduler API this code can be further improved if it is interrupted not after a certain number of iterations, but when necessary — i.e. when the browser needs to respond to user input. There is a special method for this, isInputPending:
Now the cycle will be interrupted only when necessary. For example, if the user made a click that needs to be processed.
For browsers without support for this feature, you can have a fallback for interrupting a long loop by timer.
The main difference between a microtask and a task is that a microtask is executed immediately after the current task and no other code can be executed between them.
For example, if your script encounters a
setTimeout with zero delay, you cannot guarantee that its callback will be executed immediately after the script. If an event is fired during script execution, the browser will give priority to the event handler.
On the other hand, if it is not a
setTimeout but a microtask, it will be executed first.
Thus, when you need to perform an action immediately after some task, you should schedule a microtask.
To schedule a microtask, developers sometimes use a Promise that resolves immediately. However, this method has several disadvantages: it is a trick and it requires additional overhead to create and clean up the promise.
Therefore, if you are faced with the need to create a microtask, use the
The MDN says:
Why might you need to create a microtask? For example, to batch operations. Look at this example from MDN:
No matter how many times the
sendMessage() function is called, messages will only be sent once after all calls (made within the same task).
When all tasks and microtasks are completed, the browser enters the so-called idle period. And for those who want to write efficient code, you need to take advantage of this. The idle period is the perfect time to do low priority work.
To schedule a task in the browser’s idle period, use the method:
In the options, you can pass the timeout parameter — the maximum number of milliseconds after which the callback should be called. If after this time the callback has not yet been called, the task to execute the callback will be queued into the event loop, even if this is going to negatively affect the performance.
Let’s look at some examples.
- After the script is executed, it is the turn of microtasks. We have one microtask waiting —
- Then the first task in the task queue. This is a
setTimeoutcallback that outputs
- Since there are no more microtasks, the browser executes the task again — a second
Both queues are empty, which means that it’s time to execute
requestIdleCallback. The result will be as follows:
4, 2, 3, 1
- The event loop is idle the first second after the script is executed, so it has time to execute the
- The body of the
promiseis executed synchronously. Thus, of the two
setTimeouts, the browser will put
promiseinto the task queue first. This means it will also be executed first.
- Executing this
setTimeoutcallback creates a new microtask —
- The task is completed. It’s time to complete all the microtasks. The
Promisecallback created in the previous step will be executed.
- The browser jumps to the last task — the remaining
setTimeoutcallback. So the result is:
More non-trivial challenges for understanding the Event Loop algorithm in the first part of this series of articles:
Understanding the browser’s Event Loop for building high-performance web applications. Part 1.
The most common interview question and the essential base for writing an efficient code.
Do you know how many event loops are in your app?
Try to answer the question: Will the heavy synchronous code inside the iframe delay the execution of the setTimeout cb in the parent script?
To answer this question, the only thing you need to know is whether the iframe and the parent script share the same event loop or have different ones.
If they share an event loop, then the heavy synchronous operation blocks the parent script as if it was in it. Otherwise, this operation will block iframe tasks only.
It turns out that iframes with the same origin share an event loop with the parent. Moreover, even different tabs of the same origin can share the same event loop. For example, if the second tab is opened with
This is a case where you need to be especially careful with long running tasks, because they can block the rendering of your other window.
However, your page can still have more than one Event Loop. Each individual thread has its own event loop. Worklets and workers have their own event loop.
As always, we want to encourage you to keep learning the language you write every day and let’s make IT better!
Subscribe to the telegram-channel to learn:
- what optimizations are made by browsers and how to use them
- how to reduce memory consumption and improve application performance on mobile devices
- how to avoid memory leaks
- how to help the browser render efficiently and much more.
And most importantly, all this with the help of quizzes, which make the assimilation of the material easy.
Follow us on Medium to not to miss new stuff.
Pop into our LinkedIn for other cool things we do.