Why is the EventLoop for Browsers and Node.js Designed This Way?

CodeHero
Bits and Pieces
Published in
9 min readFeb 22, 2022

--

Event Loop is a basic concept in JavaScript, and it is a must-ask in interviews and often talked about in general.

Let’s explore the reasons today.

The Event Loop for Browsers

JavaScript is used to implement web interaction logic, involving DOM operations, if multiple threads operate at the same time need to do synchronous mutually exclusive processing, in order to simplify the design to single-threaded, but if single-threaded, encounter timing logic, network requests and blocked. What can we do?

We can add a layer of scheduling logic. Encapsulate the JS code into a task queue, and the main thread will keep fetching tasks to execute.

Each time a task is fetched, a new call stack is created.

Among them, timers and network requests are actually executed in other threads, and a task is placed in the task queue after execution to tell the main thread that it is ready to move on.

Because these asynchronous tasks are executed in other threads and then notified to the next main thread through the task queue, it is an event mechanism, so this loop is called Event Loop.

These asynchronous tasks executed in other threads include timers (setTimeout, setInterval), UI rendering, network requests (XHR or fetch).

However, the current Event Loop has a serious problem, there is no concept of priority, it is only executed in order, so if there is a high priority task will not be executed in a timely manner. So, we have to design a set of queuing mechanism.

Then get a high-priority task queue on the good, every execution of a common task, go to all high-priority tasks to finish, and then go to the execution of common tasks.

With a queueing mechanism in place, high priority tasks can be executed in a timely manner. This is the browser Event Loop.

The common task is called MacroTask and the high priority task is called MicroTask.

MacroTask includes: setTimeout, setInterval, requestAnimationFrame, Ajax, fetch, script tag code.

MicroTask includes: Promise.then, MutationObserver, Object.observe.

How to understand the division of macro micro-tasks?

Timers, network requests like these are ordinary asynchronous logic that notifies the main thread after other threads have run, so they are all macro tasks.

MutationObserver and Object.observe are listening to the change of an object, the change is a very instantaneous thing, we must respond immediately, otherwise it may change again, Promise is the organization of the asynchronous process, asynchronous end call then is also very high quality.

This is the design of the Event Loop in the browser: the Loop mechanism and Task queue were designed to support asynchrony and solve the problem of logic execution blocking the main thread, and the insertion mechanism of the MicroTask queue was designed to solve the problem of early execution of high performance tasks.

But later, JS execution environment is not only a browser, there is also Node.js, it also to solve these problems, but it is designed out of the Event Loop more detailed.

The Event Loop for Node.js

Node.js is a new JS runtime environment that also has to support asynchronous logic, including timers, IO, network requests, and, obviously, Event Loop.

But the browser set of Event Loop is designed for the browser, and that design is still a bit crude for a high-performance server.

What’s crude about it?

The browser’s Event Loop is divided into only two levels of priority, one for macro tasks and one for micro tasks. But there is no further prioritization between macro tasks, and no further prioritization between micro tasks.

For example, the logic of timer Timer has higher priority than the logic of IO, because it involves time, the earlier the more accurate; and the logic of close resource processing has low priority, because not close at most more memory and other resources, the impact is not significant.

So the macro task queue was split into five priorities: Timers, Pending, Poll, Check, Close.

To explain the five macro tasks.

Timers Callback: Involving time, definitely the earlier the execution, the more accurate, so this has the highest priority is easy to understand.

Pending Callback: Callback when dealing with network, IO and other exceptions, some *niux systems will wait for the occurrence of errors to be reported, so have to deal with.

Poll Callback: Handle IO data, network connection, the server mainly deals with this.

Check Callback: The callback to execute setImmediate, characterized by the callback just after the execution of IO.

Close Callback: The callback to close the resource, which is executed later and has no impact, and has the lowest priority.

So, the Event Loop of Node.js is run like this.

There is one more difference to note in particular.

Instead of executing one macro task at a time and then executing all the micro-tasks, the Node.js Event Loop executes a certain number of Timers macro tasks, then goes on to execute all the micro-tasks, then executes a certain number of Pending macro tasks, then goes on to execute all the micro-tasks, and the remaining Poll, Check, Close macro tasks.

Why is this so?

In fact, it is easy to understand by priority.

Assume that the priority of the macro tasks in the browser is 1, so they are executed sequentially, that is, a macro task, all the micro tasks, then a macro task, then all the micro tasks.

The Node.js macro tasks are also prioritized, so the Node.js Event Loop runs all the current priority macro tasks one at a time before running the micro tasks and then the next priority macro task.

That is, a certain number of Timers macrotasks, then all microtasks, then a certain number of Pending Callback macrotasks, then all microtasks.

Why do you say a certain number?

Because if there are too many macro tasks in one phase, the next phase will not be executed, so there is an upper limit, and the remaining ones will continue to be executed in the next Event Loop.

In addition to macro tasks with priority, micro-tasks are also divided into priorities, with one more process.nextTick high priority micro-task to run before all the ordinary micro-tasks.

So, the complete flow of the Event Loop in Node.js is as follows.

  • Timers phase: execute a certain number of timers, i.e. setTimeout, — setInterval callbacks, too many for next execution
  • Micro-tasks: Execute all nextTick micro-tasks, and then execute other common micro-tasks
  • Pending phase: Execute a certain number of IO and network exception callbacks, save them for next time if too many
    Micro-tasks: Execute all micro-tasks of nextTick, and then execute other normal micro-tasks
  • Idle/Prepare phase: a phase for internal use
    Micro-task: Execute all micro-tasks of nextTick, and then execute other normal micro-tasks
  • Poll phase: Execute a certain number of data callbacks for files, connection callbacks for network, and save them for next time if there are too many. If there are no IO callbacks and no timers, check phase callbacks to handle, block here and wait for IO events
  • Microtasks: Execute all nextTick microtasks, then execute other normal microtasks
  • Check phase: Execute a certain number of setImmediate callbacks, save them for next execution if too many.
  • Micro-task: execute all micro-tasks of nextTick, and then execute other normal micro-tasks
  • Close phase: execute a certain number of close event callbacks, save them for next execution if there are too many.
    Micro-task: Execute all the micro-tasks of nextTick, and then execute other normal micro-tasks.

Compared with the Event Loop in the browser, it is obviously much more complicated, but after our previous analysis, we can understand.

Node.js prioritizes macro tasks, from high to low, Timers, Pending, Poll, Check, Close, and micro-tasks, i.e., nextTick micro-tasks and other micro-tasks.

The execution process is to execute a certain number of macrotasks of the current priority (the rest are left for the next loop), then execute the microtasks of process.nextTick, then execute the normal microtasks, and then execute a certain number of macrotasks of the next priority.

This is a continuous loop. There is also an Idle/Prepare phase for Node.js internal logic, so don’t worry about it.

It changes the way of executing one macro task at a time in the browser Event Loop, so that high priority macro tasks can be executed earlier, but also sets an upper limit to avoid not being executed in the next phase.

There is also a special point to note, that is, the poll phase: if the execution reaches the poll phase and finds that the poll queue is empty and the timers queue and check queue have no tasks to execute, then it blocks and waits for IO events, instead of idling. This design is also because the server is mainly dealing with IO, blocking here can respond to IO earlier.

The overall design of the Event Loop for both JS runtimes is similar, except that the Node.js Event Loop has a more fine-grained division between macro and micro tasks, which is easy to understand because, after all, the Node.js environment is different from the browser, and more importantly, the server side has higher performance requirements.

Summary

JavaScript was first used to write web interaction logic, and was designed to be single-threaded to avoid the synchronization problem of multiple threads modifying dom at the same time. To solve the blocking problem of single-threaded, a layer of scheduling logic was added, namely Loop loops and Task queues, which put the blocking logic into other threads to run, thus supporting asynchrony. Then to support high-priority task scheduling, a micro-task queue was introduced, which is the browser’s Event Loop mechanism: one macro task is executed at a time, and then all micro-tasks are executed.

Node.js is also a JS runtime environment, and it also uses Event Loop to support asynchronous, but the server-side environment is more complex and requires higher performance, so Node.js has a finer-grained prioritization of macros and microtasks.

Node.js has 5 types of macrotasks, namely Timers, Pending, Poll, Check, Close, and 2 types of microtasks, namely process.nextTick and other microtasks.

The Event Loop process in Node.js executes a certain number of macrotasks in the current phase (the remaining ones are executed in the next loop), and then executes all microtasks in 6 phases: Timers, Pending, Idle/Prepare, Poll, Check, Close. (Revision: this was the case before node 11, but after node 11, all micro-tasks are executed for each macro task)

The Idle/Prepare phase is used internally by Node.js, so don’t worry about it.

Pay special attention to the Poll phase, if the poll queue is If the poll queue is empty and the timers and check queues are also empty, it will block here and wait for IO until the timers and check queues have callbacks and then continue the loop.

The Event Loop is a set of scheduling logic designed by JS to support asynchronous and task prioritization, with different designs for different environments such as browsers and Node.js (mainly the granularity of task prioritization is different).

Build composable web applications

Don’t build web monoliths. Use Bit to create and compose decoupled software components — in your favorite frameworks like React or Node. Build scalable and modular applications with a powerful and enjoyable dev experience.

Bring your team to Bit Cloud to host and collaborate on components together, and greatly speed up, scale, and standardize development as a team. Start with composable frontends like a Design System or Micro Frontends, or explore the composable backend. Give it a try →

Learn More

--

--