Understanding the browser’s Event Loop for building high-performance web applications. Part 2.

Intspirit Ltd
8 min readSep 12, 2023

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.

Hermione and Ron talking about web application optimization: — Hermione, why is your app so fast? Face it, it uses a Time-Turner to process all the user’s requests. — No. I simply break up long tasks and have time to answer all user requests.

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.

Long tasks.

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:

Screenshot of a task from the chrome dev tool -> performance tab.
Screenshot of a task from the chrome dev tool -> performance tab.

Chrome marks long tasks with a red stroke and a pop-up, which indicates the duration of the task:

Screenshot of a long task from the chrome dev tool -> performance tab.
Screenshot of a long task from the chrome dev tool -> performance tab.

When long tasks are found, you need to break them into small ones. How to do it?

Quiz 1.

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?

Try yourself

Explanation.

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.

The 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:

Screenshot of a long task from the chrome dev tool -> performance tab.
Screenshot of a very long task from the chrome dev tool -> performance tab.

In such a case, we can use a combination of Promise and setTimeout:

Now, instead of one long task, we have many small ones between which the browser can perform other important tasks:

Screenshot of chrome dev tools perdormance tab. Now the cycle is divided into several tasks.
Screenshot of chrome dev tools perdormance tab. Now the cycle is divided into several tasks.

How does this code work?

When the pause function is called:

  1. The code inside the Promise is executed synchronously, causing the setTimeout callback to be put on the task queue.
  2. The pause function is popped off the stack and execution continues from where it was called.
  3. At this point, the browser encounters the await keyword. It pauses the execution of the function, the rest of the function will be executed as a microtask after the promise is resolved.
  4. Now the browser returns to the line where the foo function was called from. Since this function call does not have an await, the browser executes the rest of the code.
  5. 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.
  6. When there is no more important work left, the browser will execute our setTimeout callback.
  7. Executing the setTimeout callback will resolve the promise, which will cause the browser to jump back to executing the foo function.

There is also a Scheduler API for the same purpose. It is more flexible and allows you to explicitly prioritize tasks. But it doesn’t have great support yet.

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.

Schedule Microtasks.

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 queueMicrotask() function.

The MDN says:

The queueMicrotask() method, which is exposed on the Window or Worker interface, queues a microtask to be executed at a safe time prior to control returning to the browser’s event loop.

Syntax:

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).

Idle period.

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:

window.requestIdleCallback(callback, options)

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.

Quiz 1.

Check yourself

Explanation.

  • After the script is executed, it is the turn of microtasks. We have one microtask waiting — queueMicrotask callback. Output: 4.
  • Then the first task in the task queue. This is a setTimeout callback that outputs 2.
  • Since there are no more microtasks, the browser executes the task again — a second setTimeout callback. Output: 3.

Both queues are empty, which means that it’s time to execute requestIdleCallback. The result will be as follows: 4, 2, 3, 1

Quiz 2.

Check yourself

Explanation.

  • The event loop is idle the first second after the script is executed, so it has time to execute the requestIdleCallback callback.
  • The body of the promise is executed synchronously. Thus, of the two setTimeouts, the browser will put setTimeout inside the promise into the task queue first. This means it will also be executed first.
  • Executing this setTimeout callback creates a new microtask — Promise callback.
  • The task is completed. It’s time to complete all the microtasks. The Promise callback created in the previous step will be executed.
  • The browser jumps to the last task — the remaining setTimeout callback. So the result is: requestIdleCallback, promise, timeout

More non-trivial challenges for understanding the Event Loop algorithm in the first part of this series of articles:

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?

Check yourself

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 window.open().

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.

In conclusion.

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:

  • how modern JavaScript works
  • 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.

Resources.

  1. https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
  2. https://web.dev/optimize-long-tasks/
  3. https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide
  4. https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback
  5. https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask
  6. https://developer.mozilla.org/en-US/docs/Web/API/Scheduler
  7. https://developer.mozilla.org/en-US/docs/Web/API/Scheduling/isInputPending
  8. https://t.me/intspirit

--

--