NodeJS Architecture I: An introduction

6 min read
NodeJSJavaScriptInternalsV8Libuv
Also available in:Português

Since the start of the 2020s, advances in Artificial Intelligence have dramatically changed the way developers work. Nowadays, everybody is talking about programming in natural language without even touching a single line of code, as we used to do. This represents a huge shift in how we look at machines, but what has actually changed in the way machines look at us?

To a processor, programming in Assembly, Python or natural language is merely an abstraction for the zeros and ones that will be interpreted by its logic gates. The true value of abstractions shines when a human needs to understand specific instructions in the code or build a new feature, allowing them to work faster and more efficiently while the language engines handle the heavy lifting closer to the machine’s hardware.

Among the vast array of programming languages, JavaScript is undoubtedly one of the most popular. Initially interpreted only by browsers, it now boasts super powers and runs on almost every type of device. To bring its capabilities to backend development, NodeJS emerged in 2009, a new runtime capable of executing JavaScript code on servers, which has since built a wide community around its ecosystem.

With nearly two decades of history, much has evolved in NodeJS architecture, giving rise to a robust ecosystem for large-scale applications. Despite its importance in modern software development, many developers who use the tool have never taken the time to understand how the massive gear works, and that is exactly what we will dive into here in this series of posts.

THE SERVER-SIDE CHALLENGES:

When moving from client-side web apps to server-side execution, virtually all functionalities need to be rethought:

  • Compilation: without a browser to assist us, we must now ensure the language generates native machine code for processors;
  • I/O operations: how to handle reads and writes over the network or on disk, data throughput, timers, etc;
  • Memory usage: how to manipulate and store data, as well as how to deal with data structures native to the programming languages;
  • Concurrency and parallelism: How to execute instructions across multiple threads or divide processor capacity between operating system tasks and the application itself.

THE GEAR’S COMPONENTS

To handle all these challenges, NodeJS relies on several integrated components and resources of the operating system itself.

Initially, JavaScript was merely an interpreted language without a focus on optimization, which made it slow. However, in 2008, alongside the first release of Chrome, Google launched V8, an engine aimed at solving performance bottlenecks in its complex web apps, such as Gmail. This mechanism revolutionized browser architecture, shifting execution from “line-by-line” interpretation to a real-time compilation into native code, a process known as Just-inTime (JIT) Compilation. This pipeline consists of several stages, including parsing the code into a tree representation called AST (Abstract Syntax Tree), interpreting this structure via Ignition, and performing the final native code conversion through TurboFan.

Although V8 was not created specifically for NodeJS, it became one of its core components. Through V8, we can run the code that previously executed only in the browser directly on the server. However this isn’t the only piece required, we also need I/O operations.

When building server-side applications, we interact with the operating system through tasks like reading files, network communication, timers, and more. These are known as I/O operations. While many programming languages can communicate directly with the kernel, NodeJS runs JavaScript via V8 inside a sandboxed environment, in other words, it cannot access machine resources directly.

Imagine you are carrying a box: you represent the operating system, which handles data and controls the hardware. The box you carry is the NodeJS runtime, inside of which JavaScript code is executing. Because the code is completely isolated, accessing any external resource requires someone to “open the box” and bridge the communication with the kernel. This requirement gave rise to Libuv, a library written in C that enables I/O operations, thread management and asynchronous processing.

NodeJS simplified architecture
NodeJS simplified architecture

However, since Libuv is written in C, bringing NodeJS together requires a mechanism to integrate it with JavaScript. In the NodeJS ecosystem, the “great glue” between what happens inside and outside the box is the Bindings.

Broadly speaking, we have two major, independent components operating side-by-side, completely unaware of one another. When our application is initialized, V8 is prepared through a bootstrapping process. At this stage, NodeJS uses the V8 api to register memory pointers binding JavaScript functions to native C++ functions. NodeJS’s internalBinding function is responsible for maintaining this mapping. That way, when we code a file read operation, the Bindings send the request directly to the Libuv function that will actually handle the heavy lifting.

Initialization flow and I/O operations with NodeJS
Initialization flow and I/O operations with NodeJS

Thus, we have a complete platform to interpret JavaScript and interact with operating system calls.

However, orchestrating this entire flow is not as linear as it sounds. It relies on mechanisms such as the Event Loop, Event Queues, and more. We will explore these topics in upcoming posts.