Event Loop in Browser

Event Loop in Browser

·

6 min read

We know that there is an event loop in JavaScript, which makes JavaScript asynchronous. From a higher level, we can simply see this event loop as a while loop running constantly in the background. Inside this loop, there are several queues. What makes JavaScript asynchronous is that the current operation will not be executed immediately, but be pushed into a queue. Then the event loop will try to get a task from the queue and run it at appropriate time.

Task queue

The first queue we should meet is the task queue. Sometime these tasks are called macro tasks, compared with microtasks which we will talk about later.

Typical tasks include user clicking on a button and invoke a callback, or the tasks we schedule with setTimeout/setInterval.

So for example, below code will output 2 first then 1.

setTimeout(() => console.log(1), 0);
console.log(2);

When JavaScript runs this code, we are in the current task, which includes a setTimeout call and a console.log function call. What setTimeout is doing is just add a new task into the queue. When current task finishes, the event loop will goes into another tick and get this new task from the queue and run it. So the output 2 is in the first task, the output 1 is in the second task, so we have the result output as 2 first then 1.

Browser rendering

This event loop does not only executes tasks from task queue, it also have a lot of other tasks. A typical one is browser rendering.

For example, when user scroll the page, browser needs to update the content. Or if there is a gif image in the web page, then browser should render the gif at appropriate time constantly.

So when should the event loop run the task from task queue, and when should it runs the browser rending task? Well, the event loop runs very fast. For each tick it will try to see if there is any task in the task queue, if there is, then run it, then try to see if the browser needs to render, if it needs, then run the rendering.

So for this reason, the task we add into the task queue should be like small task and should finishes fast. If not, then it will block the event loop. For example, below code is just a simple while loop, it runs forever and it will block the event loop. Because the event loop is so busy running this code, it will never have an chance to check the browser rendering. So the user will not be able to scroll the page, it will behave like a freed web page.

while(true);

If we do have an infinity loop we need to run, we can change it like below.

function loop() {
    setTimeout(loop, 0);
}
loop();

Even if this is also runs forever, but it will not block, the web page should runs normally. Inside each task, it just add another task to the queue, then finishes. Then the event loop will do the necessary browser rendering, then check this task queue again. Because the browser rendering task always got executed so the web page should behave normally.

Animation

If we want to have an animation to move the box to the right 1px by 1px, we may write code like below.

function move() {
    moveBoxBy1Pixel();
    move();
}
move();

Will this work? Definitely not. This code is just one task moving the box forever, just like the while loop before. The browser rendering task never gets executed.

So just like below, we can use the setTimeout to add the next moving as a new task to the task queue, so the event loop have the time to run the browser rendering.

function move() {
    moveBoxBy1Pixel();
    setTimeout(move, 0);
}
move();

This could work. But if actually run this code, we should see the animation doesn't run very smooth.

The problem is about the different pace of event loop and browser rendering. The event loop runs very fast. But the broswer rendering is a lot slower and does not need to render at each tick. So the box may already be moved to the right by several pixels then we can see the real browser rendering of the box. That's why the animation is not very smooth.

What we really want is that only move the box by 1 pixel, then render it, then move again. So in the past, we simulate this process, we may use below hacking solution.

function move() {
    moveBoxBy1Pixel();
    setTimeout(move, 1000/60); // simulate rendering 60 times for every second
}
move();

Of course, this is not idea. So the browser have a new api later called requestAnimationFrame. We can use this api to add a new task. The browser will guarantee that this task will be run before every browser rendering. So this is a perfect api for animation.

function move() {
    moveBoxBy1Pixel();
    requestAnimationFrame(move);
}
move();

So now inside the event loop, in addition to the task queue and browser rendering, we have a new task to check, which is the callbacks queued by this api.

Remember that the callbacks scheduled by requestAnimationFrame will be executed before the next browser rendering. So if we need to schedule a task which need to run after the next browser rendering, we may need to use this api twice. For example, if we want to have an animation, which will move the box to the right 1000px position, then move back to 500px position, code may looks like below.

box.style.transform = "translateX(1000px)";
box.style.transition = "transform 1s ease-in-out";

requestAnimationFrame(() => {
    requestAnimationFrame(() => {
        box.style.transform = "translateX(500px)";
    });
});

Microtasks

Microtasks are just another task queue just like the macro task queue. The difference is that timing the event loop executes them.

Take a look at below code, what is the execution result?

Promise.resolve().then(() => console.log(1));
setTimeout(() => console.log(2));
console.log(3);

Well the answer is 3, 1, 2.

The key is that we need to understand what this promise doing there. This promise actually add a new task of printing 1 to the micro task queue. The order of these tasks are below.

current js code =>
microtasks =>
requestAnimationFrame callbacks =>
browser rendering =>
... another tick

Because the task of printing 2 is in the next tick and the task of printing 1 is still in current tick, so we have output with 1 before 2.

Note that these microtasks will be executed just after current js code finishes, and its possible to add another microtasks inside the microtask, so below code will behave like the while loop version, block the event loop.

// block in the microtasks forever
function loop() {
    Promise.resolve().then(loop);
}
loop();

As you can see, this microtasks are closely related with the promise. But adding microtasks with promise is a little hacking, so the broswer provides a independent api for this later, it's called queueMicrotask.

Background tasks

As you can see, the key to make this event loop process working is that we should not have a time-consuming task and make the process blocking.

If we have some low priority heavy tasks and if we schedule these tasks by hand, then we may block the event loop. The reason is that we don't know when the event loop is busy and when it is idle. So what we really want is to execute these tasks when it is idle.

The browser provides an api for this requestIdleCallback. The browser know the timing, so we can schedule these kind of tasks with this api, and the browser will schedule these properly.

requestIdleCallback(callback, options);