Unraveling the JavaScript execution pipeline: Understanding V8, event loop, and libuv for high-performance web experiences

Nalan Ozgedik
Jotform Tech
Published in
11 min readAug 9, 2023

--

JavaScript (JS) is a popular language used for web development. As a developer, I am always fascinated by its abilities and possibilities. It allows you to create dynamic, interactive, and engaging web experiences.

So, I wanted to write an article to explain the execution pipeline in JavaScript and what happens behind the scenes. Once you grasp its inner workings, you can write more efficient and responsive applications with ease.

But before we dive into the execution pipeline, let’s first review some fundamentals.

Execution context

V8 is the engine responsible for interpreting and executing JS code.

When V8 begins code execution, the source code undergoes parsing, where V8’s parser transforms it into an abstract syntax tree (AST). This tree structure represents the code’s hierarchical organization, making it easier for the engine to process.

Subsequently, V8’s Ignition interpreter takes over the execution process. The interpreter handles the code line by line over a single thread. The thread has a heap memory and a single call stack, which is basically a LIFO data structure that manages execution context and keeps track of invoked functions and running code.

Initially, the interpreter creates a special environment to handle the processing and execution of that code. This environment is called execution context (EC) and has two parts: the global execution context and the function execution context. The execution context includes the currently running code along with all the necessary elements for its execution.

The creation of an execution context actually occurs in two phases, which are the memory creation phase and the execution phase. Consequently, the V8 engine processes the code twice.

The first pass involves creation of global execution context and memory allocation. At the memory creation phase, V8 goes through the entire script and basically just puts everything into memory, including the global object.

The second pass is dedicated to executing the code. In essence, during the execution phase, V8 goes over the code again to execute it line by line. Each time it encounters a function, it generates a new function execution context and pushes the function into the call stack.

Throughout the entire execution pipeline, the global execution context is always stored at the bottom of the call stack and remains there. Meanwhile, the currently executing functions are stacked on top of it. When a function finishes its execution, it is popped from the call stack, allowing the process to repeat for subsequent functions.

Let’s dive into a detailed step-by-step analysis of what is actually happening.

Memory creation phase

Initially, when a script begins execution, the global execution context is created, representing the global scope. During the memory creation phase, the global object, which is known as “window” in browsers or “global” in Node.js, is created and stored in a stack.

All variables and functions declared in the global scope become properties and methods of that object. The global object serves as the container for various Web API objects like innerHeight, XMLHttpRequest, setTimeout(), IntersectionObserver, location, and history.

The next step performed by V8 is to bind this keyword to the window/global object.

Ultimately, V8 sets up heap memory to store reference data types. Reference data types are created on the heap and are referenced from the stack using stack pointers. On the other hand, method/function frames, primitive values, and pointers to objects are stored directly on the stack. The stack memory limit can be set using the — stack_size V8 flag. In the first pass, when V8 engine handles variables, it allocates memory and assigns them as undefined.

This final process enables us to use hoisting. Contrary to common misconceptions, the interpreter does not physically relocate functions and variables to the top of the script. Due to the memory creation phase, all function definitions and variables are already stored in the memory before the script is executed. So when we actually want to use the function before its definition, it does not give an error because it is already in memory.

The same principle applies to variables declared with the var keyword. In the first pass all of the variables are stored in memory as undefined. Therefore, if we attempt to use a variable before initializing, the engine won’t throw an error; instead, it will treat the variable as undefined since its value was set as such during the memory creation phase beforehand.

When it comes to the let and const keyword, there is an exceptional case, because they are block scoped. Contrary to common misconceptions, let and const variables are also hoisted as well. However, they are not in the global scope.

Instead, they enter a phase called Temporal Dead Zone (TDZ) during memory creation and remain there until they are officially declared. During this period, any attempt to access the variable will throw an error.

Execution phase

During the execution phase, V8 performs a second pass through the code to execute it. In this phase, it assigns the actual value to variables, skips function definitions (since there is nothing to execute as they have already been saved in memory) and pushes invoked functions into the call stack one by one. Whenever a function is called, a new function execution context is created to represent the local scope of that particular function.

When the function execution context is created, the memory creation phase and execution phase will run from scratch just for that particular function’s local variables. When the V8 creates FEC, it creates a new variable environment in the current lexical environment to store variables defined in that function during the execution phase. While the lexical environment refers to this global environment, the variable environment refers only to variables created within the scope of the provided function within the lexical environment. The variable environment maps the local scope of a given environment.

In JS, variables are resolved based on lexical scoping, meaning that the resolution is determined by the position of the variables in the source code’s lexical structure, rather than the runtime flow of the program.

Once the function execution is finished, in case there is a return statement within the function body, the return value is transferred from the function execution context to the global execution context. When the function returns, its environment is popped off the call stack, and the local variables are removed from memory (garbage collected) unless they are part of a closure and still referenced elsewhere in the program.

There are also some scenarios where performing asynchronous work is necessary to handle concurrent operations without blocking the program’s execution, such as network I/O or file system operations. When dealing with asynchronous functions, V8 needs to handle them differently to prevent blocking the main thread. So how does the V8 engine accomplish these operations in practice? By using the event loop mechanism.

Event loop

An event loop is a programming technique used to manage and process events, tasks, and callbacks in an asynchronous way. In essence, an event loop continuously checks for tasks in a queue and executes them in a sequential manner.

The main purpose of an event loop is to ensure that the program remains responsive to new events and does not get blocked by intensive tasks. Various examples of event loops are found in different environments, such as Chrome’s event loop, libuv API’s event loop, Qt event loop, Electron event loop, wxWidgets event loop, SFML event loop, or Windows message loop. These event loops ensure non-blocking execution of tasks in their environments.

Since V8 lacks its own event loop, when an asynchronous function is encountered, it is not directly executed on the call stack. Instead, V8 forwards the function to either the libuv API or Chrome’s internal event loop, depending on where the script is executed.

The event loop in Node.js, powered by the libuv API, and Chrome’s event loop both enable asynchronous behavior, but they are implemented differently.

Chrome’s event loop operates within a single main thread and ensures that JS remains non-blocking and responsive to user interactions while handling these asynchronous tasks. While the main event loop is single-threaded, Chrome loads some tasks to separate worker threads using web workers, which allow developers to perform processes in the background without blocking the main thread. Chrome’s event loop is used in various key components, including the rendering engine Blink and Web APIs.

Libuv, which is written in C (not C++, as is commonly believed) provides the necessary infrastructure for handling asynchronous operations efficiently by offering a full-featured event loop backed by epoll, kqueue, IOCP, and event ports.

Libuv’s event loop actually operates similarly to Chrome’s event loop, with the concept of an event queue and callbacks for asynchronous I/O operations. It uses the libuv library to handle asynchronous I/O operations and delegate tasks to worker threads when necessary, enabling better use of multi-core processors.

The following design overview, which is directly taken from libuv’s official website, shows its components and their relations.

In the Node.js environment, V8 wraps the function in a callback and hands it over to the libuv API’s event loop, by using C++ API. Node.js provides a C++ API (Node API) that allows developers to write C++ Bindings (C++ Addons) to create high-performance native modules that can be loaded and used within Node.js applications.

The C++ API is a crucial component, allowing developers to interact with and embed the V8 engine within C++ applications. V8 uses C++ API to expose C++ functions as JS functions. This way, applications can invoke C++ code directly from JS code running in the V8 engine.

By offering its components, the v8.h header file enables the use of the V8 engine’s capabilities to build custom JS environments. It allows access to various functionalities, including creating V8 isolates, contexts, and JS values, managing garbage collection, executing JS code, and handling other related tasks.

How does the event loop of the libuv API work?

Before diving into how the event loop runs, let’s define some essential concepts to understand the flow better.

  • Handles: These represent long-lived objects such as timers, signals, and TCP/UDP sockets. Once a task is completed, handles will trigger the appropriate callbacks. The event loop will keep running as long as a handle remains active.
  • Requests: Represent short-lived operations, such as reading from or writing to a file or establishing network connection. Like handles, active requests will keep the event loop alive.
  • Thread pool: Libuv assigns all the heavy work to a pool of worker threads. This thread pool is responsible for handling tasks like file I/O and DNS lookup.

Now that we’ve defined these concepts, here’s an overview of how the event loop works:

  1. At the start of each loop iteration, the event loop calculates the current time (now) and keeps it as a reference throughout the iteration. The computed time is cached to minimize the need for frequent system calls.
  2. The event loop checks whether there are pending tasks to be executed to determine if the loop is still active. As mentioned earlier, the loop is considered alive if there are active handles or requests. If there are no active tasks, the loop will end.
  3. Due timers are run if the loop was run with UV_RUN_DEFAULT. In this stage, there is a distinct queue of callbacks, which are scheduled to run using functions like setTimeout or setInterval.
  4. Next, Pending callbacks are called.
  5. Idle handle callbacks are called to perform low-priority tasks during periods when the event loop is not busy with other time-critical operations. Idle handles are useful when you have tasks that need to be done regularly but do not need to be executed immediately or in response to specific events.
  6. Prepare handle callbacks are executed to perform any preparatory tasks before polling for I/O, such as updating data structures or configurations.
  7. During pool for I/O phase, two primary actions take place. First, the event loop checks the I/O queue and processes pending callbacks from previously completed I/O tasks. Then, it calculates a timeout value, which signifies the duration the event loop will block and wait for I/O operations to occur. Instead of performing I/O operations on the event loop’s main thread, which could potentially block the event loop and make it less responsive, libuv delegates these operations to the I/O pool’s worker threads.
  8. Check handle callbacks are executed right after I/O polling to handle setImmediate callbacks.
  9. Close callbacks are executed. These callbacks are scheduled for execution when libuv disposes of an active handle.
  10. The loop concept of “now” is updated and iteration ends.

Transferring outcomes from libuv to V8

When an asynchronous function completes its execution, libuv calls V8 by using handles and uv_async_send() to trigger the callback function. This callback function serves as a notification to V8 that the asynchronous task has finished. V8 sends the result to the event queue (task queue) whenever the call stack is available.

The event queue, operating on a FIFO principle, becomes significant at this point. Once libuv finishes executing the asynchronous function, it adds the corresponding callback to the task queue. The event loop, responsible for managing the execution flow, retrieves an event from the task queue and pushes it onto the stack. The call stack then handles the callback function or offloads it to lower-level APIs for further processing. This allows v8 to effectively manage and handle asynchronous operations without blocking the main thread.

“Promises” are a distinct category of asynchronous tasks and follow a specific flow. Once a promise is resolved or rejected, it is placed in a designated space known as the microtasks queue.

According to the WHATWG specification, during a single tick of the event loop, just one macrotask from the macrotask queue should be handled. Once this macrotask is completed, all other existing microtasks should be sequentially processed within the same tick. As microtasks have the ability to enqueue additional microtasks, the event loop continues to run these microtasks one by one until the microtask queue becomes empty.

Consequently, the microtask queue holds a higher priority when it comes to execution compared to the macrotask queue (event queue). This working structure ensures that promises and their associated tasks are handled promptly and efficiently by giving them higher priority in execution.

As a developer, my own feeling is that these technologies have changed web applications a lot and have allowed creating very interactive and high-performance user experiences. The ongoing development of these technologies and the hard work of the developer community have made me passionate about JS as a language, and I’m excited to use it more.

Although there may be difficulties in understanding its asynchronous nature, the benefits of building interactive and efficient applications with JavaScript are totally worth it. As the web keeps changing, I can’t wait to see how these technologies will influence the future of digital experiences.

References:

ECMAScript Language Specification — ECMA-262 Edition 5.1

Event loop processing model

Design overview — libuv documentation

let — JavaScript | MDN

14 ECMAScript Language: Statements and Declarations

Memory management — JavaScript | MDN

Getting started with embedding V8

--

--