Elliott Chen logo single outlinedElliott Chen logo solidElliott Chen logo text

A Deep Dive of Eventloop

by Elliott Chen, a developer

Ever since I embarked on my journey as a full-time web developer, I found myself entranced by the intricate workings of the digital realm. Many aspects of web development initially appeared as enigmatic, their inner workings shrouded in a veil of complexity. However, one pivotal moment shifted my perspective entirely.

One day, it dawned on me just how intricate and multifaceted Google Chrome, the ubiquitous web browser, truly is. It became abundantly clear why only a select few individuals possess the expertise to create browsers within the industry. Chrome, to me, transcended mere software; it evolved into a masterpiece of aesthetics, an embodiment of both mechanism and engineering. Consequently, I affectionately regard it as a form of artistry in the digital landscape.

Note:

This is gonna be a long ride with me, so buckle up before I put the gear on, let's have fun!

The eventloop in browser

In web development, the theory of everything, the one and only: Eventloop.

1.0 The process

What is process and how does it work?

In a general, Chrome's processing is a sophisticated orchestration of tasks that begins with user input, proceeds through DNS resolution and HTTP requests to web servers, and culminates in the rendering of web content. Chrome employs a rendering engine to parse HTML, CSS, and JavaScript, constructing the DOM tree and rendering layouts while executing JavaScript for interactivity. It manages network connections for resource fetching, caches certain assets, and integrates seamlessly with browser extensions for added functionality. The browser's robust security features safeguard users against threats, while support for web APIs enables developers to create dynamic web applications. Chrome's constant updates ensure it remains a reliable and secure platform for users and an essential tool for web developers.

But, we don't have to worry about all of them, within the context, as how website works, we must know how processor deals with multip running applications.

Think of Chrome's role as that of a memory manager, responsible for overseeing multiple running applications. For instance, if YouTube requires additional memory to function smoothly while Spotify needs less memory to maintain its current state, Chrome's memory management system can dynamically adjust the allocation of memory resources to each running app. This reallocation ensures that each application receives the appropriate amount of memory to optimize its performance, allowing them to run concurrently.

Let's take a look at this illustration of how memory mangement works in process:

In summary, within Chrome, each running application operates within its isolated process, and Chrome's memory management dynamically allocates memory resources to these processes. While these processes can communicate with each other, they must establish agreement protocols for effective communication.

1.1 The thread

What is thread and how does it work?

After acquiring memory through a process, the next step is to execute code, and this is where threads come into play. Each process must have at least one thread for code execution, typically referred to as the main thread, which is created by the process itself.

However, when the main thread becomes overwhelmed with tasks and cannot handle all the executions efficiently, it will initiate additional threads to assist in the workload. This is why a single running thread can encompass multiple sub-threads.

let's take a look at this illustration:

1.2 Processes and threads in Chrome

To ensure stability, Chrome employs a multi-process and multi-threaded architecture. From the perspective of web engineers, the key processes to be aware of are the main process (also known as the browser process), the internet process, and the rendering process.

Note:

All processes can be found in the task manager within the Chrome setting.

And here is another illustration shows three processes:

Let's take a look and see what each process does:

  1. Main Process: Chrome UI(Chrome's own UI, it has nothing to do with other websites' UI), user interactions, management of sub-processes. We gonna cover more on this part later.
  2. Internet Process: Internet related tasks. We gonna cover more on this part later.
  3. Rendering Process: Rendering process will start a rendering main thread that takes care of execution of HTML, CSS and JavaScript. By defualt, Chrome does Full Site Isolation, AKA site-per-process. For more info, check out the offical Chromium Doc.

2.0 Main thread of rendering

Okay, we are finally here. The main thread of Rendering is what they say, the busiest thread ever, here is what it does in the full process of renderer:

Other than the tasks as shown in the illustration, it also needs to deal with JavaScript:

  • Render FPS is 60 for the smoothness when we scroll.
  • Execute JavaScript globablly.
  • Execute Event Listener for any user interaction.
  • Execute setTimeour callback for async.
Note:

Think about it, instead of executing all tasks on main thread of rendering, why doesn't the Chrome start mutiple threads to handle different tasks?

Now we know that all the executions need to run on the main thread of rendering, then how Chrome dispatches all the tasks?

Let's look at them one by one:

  • If there is an upcoming user interaction, for example onClick button, while Chrome is executing a JavaScript function, at this moment, should or should not Chrome stops what is doing and then respond to the onClick button immediately?
  • If there is a setTimeout time hits zero, at this moment, should or should not Chrome stops what is doing and then respond to the callback of the setTimeout function?
  • If the user interaction and the timer happens at the same time, which task should Chrome execute immediately?
  • ...

Because there is only one thread of rendering, so Chrome has to do something to make it work, its solution is Queue.

Let's take look at the illustration:

  1. Initially, the main rendering thread enters an infinite loop.
  2. During each iteration of this loop, it checks for the presence of tasks in its queue. If there are tasks, it executes them and then starts the loop anew. If the queue is empty, the main thread enters a state of hibernation.
  3. Other threads can enqueue tasks into the message queue at any time. If the main thread is in a state of hibernation, it will be awakened, starting the loop once again to process these newly added tasks.

This entire sequence of events, from the continuous loop to task retrieval and addition to the queue, collectively constitutes what is commonly referred to as the event loop.

Let's look at the source code of Chromium, on line 4 starts the infinite loop by running for(;;), and on line 9, it doWork() to start over the loop:

void MessagePumpDefault::Run(Delegate* delegate) {
  AutoReset<bool> auto_reset_keep_running(&keep_running_, true);

  for (;;) {
#if BUILDFLAG(IS_APPLE)
    apple::ScopedNSAutoreleasePool autorelease_pool;
#endif

    Delegate::NextWorkInfo next_work_info = delegate->DoWork();
    bool has_more_immediate_work = next_work_info.is_immediate();
    if (!keep_running_)
      break;

    if (has_more_immediate_work)
      continue;

    has_more_immediate_work = delegate->DoIdleWork();
    if (!keep_running_)
      break;

    if (has_more_immediate_work)
      continue;

    if (next_work_info.delayed_run_time.is_max()) {
      event_.Wait();
    } else {
      event_.TimedWait(next_work_info.remaining_delay());
    }
    // Since event_ is auto-reset, we don't need to do anything special here
    // other than service each delegate method.
  }
}

Here's an interesting tidbit of trivia: while the concept of an event loop is commonly referred to in the W3C context, in Chromium, it goes by the name message_loop Despite the different terminology, both terms essentially represent the same fundamental concept. If you were to examine the source code of Chromium, you'd notice it being referred to as a message loop in the project's codebase.

2.1 Async

Why Async and what is Async?

While the code is running, there are gonna be tasks won't require to do immediately:

  • Callback timer: setTimeout, setInterval
  • Requests: XHR, Fetch
  • User interaction: addEventListener

If the main thread of a web browser is blocked or jammed up by tasks that take a long time to complete, it can lead to a poor user experience, including unresponsiveness and, in some cases, even a browser crash.

Let's look at the these 2 illustrations of setTimeout in sync and in async:

In this way, remember main thread of rendering will never be jammed up.

2.2 Priorities

There are no priorities for tasks, but there are for queues.

  1. If the event loop's performing a microtask checkpoint is true, then return.

  2. Set the event loop's performing a microtask checkpoint to true.

  3. While the event loop's microtask queue is not empty:

Note:

I saw a lot of docs are referring that there are Microtask Queue and Macrotask Queue, actually as browser complexity increases, W3C no longer uses the term Macrotask Queue. For more info please read W3C microtask checkpoint.

In Chrome, there are 3 priorities for 3 different queues:

  • Highest: Microtask Queue
  • High: Task Queue(Message Queue)
  • Medium: Timer Queue

The primary way of adding tasks to Microtask Queue are using Promise and MutationObserver.

For example:

// Put promise into Microtask Queue immediately
Promise.resolve().then()
Note:

FYI, Chrome also has a lot more other queues, in this post we only address the queues related to the eventloop.

Let's look at some code:

console.log(1)

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

Promise.resolve().then(() => {
  console.log(3);
}).then(() => {
  console.log(4);
})

console.log(5);

// output: 1, 5, 3, 4, 2

Alright, the output is 1, 5, 3, 4, 2. Let's go through it:

  1. The main thread sees the task of console.log(1) then puts that into the execution stack ready to be fired.
  2. It then sees setTimeout, puts it into timer thread ready to be put into execution stack.
  3. It then sees Promise, puts it into Microtask Queue ready to be put into execution stack.
  4. It then sees console.log(2), puts it into the execution stack right after console.log(1).
  5. Fire the execution stack.

Let's look at another one:

const p1 = Promise.resolve().then(() => {
 console.log(1)
 const s1 = setTimeout(() => {
  console.log(2)
 }, 0)
})

const s2 = setTimeout(() => {
  console.log(3)
  const p2 = Promise.resolve().then(() => {
    console.log(4)
  })
}, 0)

// output: 1, 3, 4, 2

Okay, this snippet of code is tricky. Let's go through it.

  1. First of the first, by the nature of the async and the eventloop, both p1 and s2 are not in the Call Stack, so they need to be put into their own queues and wait to be put into the Call Stack for execution.
  2. p1 goes to Microtask Queue.
  3. s2 goes to Timer Queue.
  4. p1 then gets put into the Call Stack, which will be executed immediately.
  5. output 1
  6. While inside the p1 function, s1 gets to put into Timer Queue.
  7. s2 goes into the Call Stack, and is executed immediately.
  8. output 3
  9. while inside the s2 function, p2 gets to put into Microtask Queue.
  10. Now we have p2 left in the Microtask Queue, and s1 left in the Task Queue.
  11. output 4
  12. output 2

Step 1 in illustration:

Step 2 in illustration:

Spend sometime trying your best to wrap your head around it, you will gain a lot.

Note:

I asked ChatGPT about the output of this question, and guess what, ChatGPT gives wrong answers 10/10. So, trying your best to understand it well, then that's something can be showing off.

One more thing about setTimeout, actually it is not accurate as you would think, let's look at the source code of Chromium:

// Step 11 of the algorithm at
// https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html requires
// that a timeout less than 4ms is increased to 4ms when the nesting level is
// greater than 5.
constexpr int kMaxTimerNestingLevel = 5;
constexpr base::TimeDelta kMinimumInterval = base::Milliseconds(4);
constexpr base::TimeDelta kMaxHighResolutionInterval = base::Milliseconds(32);

When the nested level is more than five, there will be four milliseconds of differences.


The eventloop in Node

To be continued...

More articles

ui = f(data)(state)

What UI means to when functional programming comes in.

Read more

Consistently bad is better than inconsistently good

Ultimately, the quintessential quality that defines a stellar engineer is unquestionably consistency.

Read more

Let’s talk.

My location

  • Elliott Chen | Studio
    Pudong APT #213
    Shanghai, China