Node.js architecture
Learn how V8, libuv, C++ bindings, Node-API, built-in modules, primordials, and startup snapshots combine in Node.js.
- 01Name the partsSay what V8, libuv, C++ bindings, and Node's modules each do.
- 02Follow a file readFollow one file read from your JavaScript, down to the operating system, and back to your callback.
- 03Explain the deeper toolsExplain Node-API addons, built-in modules, primordials, and startup snapshots in your own words.
Node is made of several parts
Node.js is a runtime. A runtime is a program that runs your JavaScript and gives it extra tools. Node gives JavaScript tools to work with files, networks, timers, and other programs.
Node is built from a few parts. Each part has one job:
- V8 runs your JavaScript. It is the same engine Chrome uses.
- libuv is a library written in C. It runs Node's event loop and talks to the operating system.
- Bindings are small pieces of C++ code. They connect a JavaScript function, such as
fs.readFile, to the C++ code that does the work. - Modules such as
fsare the JavaScript functions you call.
Node.js architecture means the parts of Node and how they fit together: V8, libuv, the C++ code and bindings, and the JavaScript modules you call.
Node can tell you which parts it has. The object process.versions holds one version number for each part. Object.keys gives a list of its names, and .includes checks if a name is in that list.
console.log(
Object.keys(process.versions).includes("v8"),
Object.keys(process.versions).includes("uv"),
);Line 2 checks for the name v8. Line 3 checks for the name uv, which is libuv. In Node, this prints true true. So Node has both parts inside it.
true true.A version name only tells you that a part is there. It does not tell you what the part does. The sections below explain each part's job, and where your JavaScript hands work to C++ code.
V8, libuv, and bindings
V8 reads your JavaScript and runs it. It also looks after the memory your objects use. But V8 alone has no fs module. It also does not know how to start a timer on your computer.
libuv is a library written in C. Node uses it for three jobs:
- It runs the event loop, which decides which callback runs next.
- It keeps a small group of helper threads for slow work.
- It talks to Windows, macOS, and Linux, so the same Node code works on all of them.
Think of a car. V8 is the engine. libuv is the wheels and gearbox that touch the road. The C++ bindings are the wires that join the pedals and steering wheel to the machine underneath.
- In real life: Engine
- In JavaScript: V8 runs JavaScript
- In real life: Wheels and gearbox
- In JavaScript: libuv does the work with the operating system
- In real life: Wires from the pedals and wheel
- In JavaScript: C++ bindings connect JavaScript functions to C++ code
- In real life: Road
- In JavaScript: Files, networks, timers, and the operating system
const { node, v8, uv, napi } = process.versions;
console.log(Boolean(node), Boolean(v8), Boolean(uv), Boolean(napi));Line 1 takes four values out of process.versions: node, v8, uv, and napi. Line 2 turns each one into true or false with Boolean. In Node, it prints true true true true.
Each value is a version number for one part:
nodeis the version of Node itself.v8is the version of the V8 engine inside it.uvis the version of libuv.napiis the newest Node-API version this Node supports. You will meet Node-API later in this lesson.
These are only names and numbers. They do not tell you how fast Node is.
The table below compares four parts. Each row says what the part does, and what people often think it does but it does not.
| Part | What it does | What it does not do |
|---|---|---|
| V8 | Runs your JavaScript and looks after the memory your objects use. | Read files, start timers, or open network connections on its own. |
| libuv | Runs the event loop, keeps a small group of helper threads, and talks to Windows, macOS, and Linux. | Run JavaScript, or do every job that Node offers. |
| C++ bindings | Connect a JavaScript function, such as fs.readFile, to the C++ code that does the work. | Count as an addon that you write yourself. |
| Node-API | Gives addons a set of C functions that keep working in newer Node versions. | Make every C++ or V8 function keep working in newer Node versions. |
const layers = {
v8: "run JavaScript",
libuv: "coordinate event-loop and platform work",
binding: "translate JavaScript values at a native boundary",
module: "offer a documented JavaScript API",
};
console.log(layers.v8);
console.log(layers.binding);
console.log(layers.module);Lines 2 to 5 give each part one short job. Lines 8 to 10 print three of them: run JavaScript, translate JavaScript values at a native boundary, and offer a documented JavaScript API. Here, "native" means C or C++ code, and the "boundary" is the place where JavaScript hands work to that code.
Short jobs help you ask a clear question: which part is doing this?
Most of the time, your code only touches the modules. V8, libuv, and bindings are real, but a normal program starts with a module such as node:fs or node:crypto.
Follow a file read
Let's follow one call: fs.readFile("menu.txt", callback). It asks Node to read a file. A callback is a function you pass in, so Node can call it later with the result.
Here is what happens, step by step:
- Your JavaScript calls the Node function
fs.readFile. - A C++ binding passes the request to C++ code.
- libuv does the slow work, or hands it to another C++ library.
- The operating system reads the file from the disk.
- Node calls your callback with the text or an error.
The exact steps change from one function to another, and from one operating system to another. So the model below uses broad names, not the real names of Node's hidden functions.
libuv keeps a small group of helper threads, called the thread pool. A thread is a worker that can run at the same time as your JavaScript. In Node 22, the pool has four threads by default. These jobs use it:
- file reads and writes in
fsthat take a callback or return a promise dns.lookup(), which turns a website name into an address- some
cryptofunctions zlibfunctions that take a callback, which pack and unpack data
You can change the pool size with UV_THREADPOOL_SIZE before Node starts. More threads are not always faster.
Some work does not use the pool at all. Network connections ask the operating system to say when data arrives, so there is no pool thread for each connection. dns.resolve*() asks a DNS server directly, also without the pool.
function callPath(api) {
return ["JavaScript API", "C++ binding", "libuv or native subsystem", "operating system", "JavaScript callback"];
}
console.log(callPath("fs.readFile").join(" -> "));Line 2 lists the steps in order. Line 5 calls the function and joins the steps with arrows. It prints JavaScript API -> C++ binding -> libuv or native subsystem -> operating system -> JavaScript callback. This is a model made for learning. It is not output from Node itself.
A callback does not start a second copy of your program. It is a function that Node calls once the file is read. While Node waits for the file, your JavaScript can keep doing other work.
For a file read, Node must turn your strings into a request the operating system understands. The next example shows the part of that request your code writes. It only runs in Node, because a browser page cannot read files from your disk.
const fs = require("node:fs");
fs.readFile("menu.txt", "utf8", (error, text) => {
if (error) throw error;
console.log(text);
});Line 1 loads Node's file module, fs. Line 3 asks it to read menu.txt as text ("utf8") and passes a callback. The callback gets two values: error and text. Line 4 throws if something went wrong. Line 5 prints the text.
In Node 22, this file read uses libuv's thread pool. When the read is done, the event loop runs the callback.
Step through a small model of a file read. It replays the lesson code. It does not show Node's hidden functions.
script
return ["JavaScript API", "C++ binding", "libuv or native subsystem", "operating system", "JavaScript callback"];} console.log(callPath("fs.readFile").join(" -> "));Once the callback starts, it is normal JavaScript again. It can change an object, throw an error, or call another Node function. Passing through C++ does not change the callback in any way.
function callPath(api) { return ["JavaScript API", "C++ binding", "libuv or native subsystem", "operating system", "JavaScript callback"];} console.log(callPath("fs.readFile").join(" -> "));JavaScript APIC++ bindinglibuv or other C++ codeoperating system or a C libraryJavaScript callbackIn Node 22, a file read goes through a binding to libuv's thread pool. A helper thread reads the file. Then Node gives your callback the text.
Bindings: the bridge to C++
A binding is a small piece of C++ code that sits between a JavaScript function and the C++ code that does the work. It is a bridge. It is not a second JavaScript engine.
JavaScript and C++ store values in different ways. So the binding changes JavaScript values, such as strings, into C++ values. When the work is done, it changes the C++ result back into a JavaScript value.
Before the binding, a JavaScript part of Node does some work too. It checks your arguments, builds a request, and remembers which callback or promise gets the result. Because of all this, you can just write fs.readFile. You never deal with the operating system's own file commands.
function invokeBinding(name, argument) {
return { name, argument, nativeRequest: true };
}
const request = invokeBinding("readFile", "menu.txt");
console.log(request.name, request.argument, request.nativeRequest);Lines 1 to 3 make a pretend binding called invokeBinding. It puts the function name and the argument into one object, with nativeRequest: true to mark it as a request for C++ code. Line 5 calls it with "readFile" and "menu.txt". Line 6 prints readFile menu.txt true.
A real binding must keep JavaScript's rules while it talks to C++. It has these jobs:
- change strings and callbacks into C++ values
- check for errors
- change the C++ result back into a JavaScript value
This is why fs.readFile looks the same on every operating system, even though each one reads files in its own way.
Bindings also hide details you don't need. For example, a file function can give you a promise and never show you the number the operating system uses for the open file. Use the functions Node documents. If one is too slow, measure it before you look deeper.
Replay a small model of a C++ binding. It shows where JavaScript hands work to C++ code. It is not Node's real code.
script
return { name, argument, nativeRequest: true };} const request = invokeBinding("readFile", "menu.txt");console.log(request.name, request.argument, request.nativeRequest);Real bindings do even more. They pass errors back to JavaScript, free memory when it is no longer needed, and finish work that happens later. The small model above only shows one thing: the place where JavaScript hands work to C++.
Node-API and native addons
A native addon is a piece of C or C++ code that you write, turn into machine code, and load into Node. People use addons when JavaScript is too slow for a job, or to reach a C library.
Node-API is a set of C functions for writing addons. Its old name was N-API. If your addon only uses Node-API, you can build it once and keep using it in newer Node versions. The short name for this promise is ABI stability (ABI means "Application Binary Interface", the way built machine code talks to other machine code).
A common tool for building an addon is node-gyp. The built file usually ends in .node. You load it with require, like a normal module.
#include <node_api.h>
napi_value AddOne(napi_env env, napi_callback_info info) {
napi_value result;
napi_create_int32(env, 4, &result);
return result;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, AddOne)Here is what the C++ code does, line by line:
- Line 1 loads Node-API's list of functions, the file
node_api.h. - Lines 3 to 7 make a function that creates the JavaScript number
4. Anapi_valueis how C code holds a JavaScript value. - Line 9 tells Node which function starts the addon.
This is a short sketch. It is not a full addon you can build.
The promise only covers Node-API. If your addon calls V8, libuv, or Node's own C++ code directly, you must rebuild it for each new major Node version.
The promise also has limits. You still need a separate build for each operating system and each kind of processor. Any other C library your addon uses has its own rules too.
function chooseImplementation(needsNativeSpeed, needsBrowserPortability) {
if (needsBrowserPortability) return "WebAssembly or pure JavaScript";
if (needsNativeSpeed) return "Node-API addon";
return "pure JavaScript package";
}
console.log(chooseImplementation(false, false));
console.log(chooseImplementation(true, false));
console.log(chooseImplementation(true, true));Line 2 checks first: must the code also run in a browser? Then it picks WebAssembly or plain JavaScript. WebAssembly is fast compiled code that runs in both browsers and Node. Line 3 picks a Node-API addon only when the work needs C++ speed. Otherwise, line 4 picks a plain JavaScript package.
Lines 7 to 9 print pure JavaScript package, Node-API addon, and WebAssembly or pure JavaScript.
Only pick an addon when you need it. It brings extra work: a C++ compiler, a separate build for each system, more tests, and the risk of crashing the whole program. First measure the slow part. Then pick an addon only if it really helps.
Built-in modules
Built-in modules are modules that come with Node. You don't install them. Some examples are fs (files), path (file paths), stream (data that arrives in pieces), and worker_threads (extra threads for JavaScript).
You can write the name with node: in front, such as node:fs. This makes it clear that the module comes with Node, and is not a package you installed.
The module node:module has a list called builtinModules. It holds the name of every built-in module. Tools use it to tell a built-in module apart from a package in your project's node_modules folder.
const { builtinModules } = require("node:module");
console.log(builtinModules.includes("fs"));
console.log(builtinModules.includes("worker_threads"));Line 1 gets the builtinModules list. Line 2 checks for fs, and line 3 checks for worker_threads. In Node, both lines print true.
You call built-in modules from JavaScript. Inside, some of their code is JavaScript and some is C++. That mix can change in a new Node version. So rely on what the Node docs say a module does, not on how its files are laid out inside Node.
Primordials: Node's saved built-ins
Your code can replace built-in methods, such as Array.prototype.join. If Node's own code used those methods, your change could break Node.
So Node saves its own copies of the built-in methods when it starts. These saved copies are called primordials. Node's own code uses them. If your code later replaces a method, Node's saved copy stays the same.
Imagine a home kitchen. The cook keeps their own knife in a pocket. A guest swaps the knife in the drawer for a different one. The cook still cooks with their own knife, so dinner is fine.
- In real life: The cook's own knife
- In JavaScript: Node's saved copy of a built-in method
- In real life: A guest swaps the knife in the drawer
- In JavaScript: Your code replaces a built-in method
- In real life: The cook keeps cooking with their own knife
- In JavaScript: Node keeps using its saved copy
const before = ["tea", "milk"].join(",");
Array.prototype.join = () => "changed by app code";
console.log(before);
console.log(["tea", "milk"].join(","));Line 1 runs the normal join and saves the result, tea,milk. Line 2 replaces Array.prototype.join with a new function. Line 3 prints tea,milk. Line 4 calls join again, gets the new function, and prints changed by app code.
This shows why a saved copy matters. It does not show Node's real primordials, which your code cannot reach.
const savedJoin = Array.prototype.join;
Array.prototype.join = () => "changed by app code";
console.log(savedJoin.call(["tea", "milk"], ","));
console.log(["tea", "milk"].join(","));Line 1 saves the original join in savedJoin, before anything changes. Line 2 replaces the shared method. Line 4 calls the saved copy with .call. .call runs a function on the object you give it, here the array ["tea", "milk"]. So line 4 prints tea,milk. Line 5 looks up join the normal way, finds the new function, and prints changed by app code.
tea,milk. Line 5 looks up join again and gets the app's new function.Node's own guide for contributors is careful here. Primordials are only for Node's own code. Some changes to built-ins can still break Node or your app. So think of primordials as a way to make Node more reliable. They are not a tool for your app, and they are not a reason to change built-in methods.
Startup snapshots
When Node starts, it does some setup work before your program runs. Your own program may do more setup too. Doing the same setup every time takes time.
A startup snapshot is a saved copy of the memory after setup. Node can load that copy when it starts, so it skips the setup work. A snapshot cannot save things that are still running, such as open network connections or a request that is in progress.
You build and use a snapshot with two flags:
--build-snapshotruns a script and saves the result in a file called a blob.--snapshot-blobnames that blob file.
Before Node loads a blob, it checks that the blob fits the computer and the Node that is running. It checks the Node version, the operating system, the kind of processor, and V8's settings.
node --snapshot-blob snap.blob --build-snapshot entry.js
node --snapshot-blob snap.blob main.js
// entry.js
const v8 = require("node:v8");
console.log(v8.startupSnapshot.isBuildingSnapshot());Line 1 runs entry.js and saves the result in snap.blob. Line 2 loads snap.blob, then runs main.js. In entry.js, line 6 prints true, because v8.startupSnapshot.isBuildingSnapshot() is true while Node builds the snapshot.
// builder.js
const v8 = require("node:v8");
globalThis.menuName = "tea";
v8.startupSnapshot.setDeserializeMainFunction(() => {
console.log(globalThis.menuName);
});
// Build: node --snapshot-blob app.blob --build-snapshot builder.js
// Load: node --snapshot-blob app.blobLine 3 saves the name "tea" on globalThis. Line 4 gives Node a function to run when it later starts from the snapshot. That function, on line 5, prints tea. Lines 8 and 9 show the two commands to build and load it.
Only save things that come out the same every time. An open network connection, a request in progress, or the current time do not belong in a snapshot.
function canLoadSnapshot(saved, running) {
return saved.node === running.node
&& saved.platform === running.platform
&& saved.arch === running.arch
&& saved.v8Flags === running.v8Flags;
}
const saved = { node: "22", platform: "linux", arch: "x64", v8Flags: "default" };
console.log(canLoadSnapshot(saved, saved));
console.log(canLoadSnapshot(saved, { ...saved, arch: "arm64" }));Lines 2 to 5 compare four things about the saved snapshot with the running Node: the Node version, the operating system, the processor type (arch), and the V8 flags. Line 9 compares the saved values with themselves, so it prints true. Line 10 changes the processor type to arm64, so it prints false.
Real Node does checks like these. It refuses a blob that does not fit, instead of loading it anyway.
Keep snapshot code simple, and make it give the same result every time. Node's docs also list which modules are safe to load while you build a snapshot.
Which part to look at first
Most Node developers only use the JavaScript modules: fs, http, timers, streams, and promises. Knowing the parts underneath helps you ask better questions. This matters when code is slow, must run on many systems, or uses an addon.
When something feels slow, first name the Node function you call, and what the docs say it does. Then ask what kind of work it is:
- a file read
- a DNS lookup (turning a website name into an address)
- a
cryptojob, such as making a hash - a timer
- plain JavaScript math
Then pick the right Node or operating-system tool to measure it. The word "asynchronous" alone does not tell you which part does the work.
- Read and run your JavaScript
- Look after the memory your objects use
- Run a timer's callback when the time is up
- Read a file while your JavaScript keeps going
- Connect
fs.readFileto C++ code - Make a JavaScript value inside an addon
Put each job under the part of Node that does it. Read the explanation after you place each card.
A few simple rules:
- For a package other people will use, use the functions Node documents.
- For an addon, use Node-API if it has the C functions you need.
- For normal app code, changing Node's own bindings is almost never the answer.
Read a crash report in order
Node can write a crash report when your program crashes. It is a JSON file with many fields. Not every field explains the crash. Read it in this order: first the Node version and the JavaScript stack, which lists the functions that were running. Only then look at libuv's handles and the resource numbers, and only if they help explain the crash you found.
// Start Node so a crash writes a report.
node --report-uncaught-exception server.js
// In the JSON report, read these in order:
// 1. header.nodejsVersion: which Node is running.
// 2. javascriptStack: which of your functions were running.
// 3. libuv: open handles, only after you find the bug.
// 4. resourceUsage: CPU and memory, next to a normal run.Line 2 starts Node so it writes a report when an error is not caught. The numbered comments on lines 5 to 8 give the order to read the report. They are not a fix. They stop you from jumping straight to libuv when the JavaScript stack already shows the bug, such as a missing value.
For a slow start, first write down the setup steps. Measure each one on its own: loading settings, creating clients, and loading modules. A snapshot can help with setup that is the same every time. It cannot fix a slow network call or work your app does not need.
const startedAt = performance.now();
require("./load-config.js");
require("./create-client.js");
console.log(Math.round(performance.now() - startedAt));Line 1 notes the start time with performance.now(), which gives the time in milliseconds. Lines 2 and 3 run two setup steps. Line 4 prints how many milliseconds both steps took. Split the steps further only after this first number shows where the time goes.
This only runs in Node. Run it in your app's folder, where load-config.js and create-client.js exist. The browser editor has no require.
Common misconceptions
- “Node is just V8.” V8 only runs JavaScript. Node adds modules, ways to reach the operating system, and its own C++ code.
- “Async means a new JavaScript thread.” Node has several ways to wait for work. Some work uses the thread pool, and some does not. Your callbacks still run on the one JavaScript thread. The word "async" alone does not tell you which way is used.
- “Node-API makes any C library work everywhere.” Its promise only covers Node-API itself. Other C libraries, and direct use of V8 or libuv, have their own rules.
- “Primordials are a safety tool for my app.” They are only for Node's own code, and they have limits.
- “A snapshot saves a running server.” It only saves setup work that is allowed and comes out the same every time. It cannot save open connections.
The table below lists words that sound alike. Each row says what the word means, and what it does not mean.
| Word | What it means | What it does not mean |
|---|---|---|
| V8 | The engine inside Node that runs JavaScript. | All of Node.js. |
| libuv | A C library that runs the event loop and talks to the operating system. | A rule that every async job uses a helper thread. |
| A binding | C++ code behind a JavaScript function that Node gives you. | A JavaScript function you wrote. |
| Primordials | Node's own saved copies of built-in methods. | A tool for your app, or a guard against every change. |
| Snapshot | Saved setup work that a matching Node can load when it starts. | A saved copy of a running program. |
When you debug, first say which part you mean. "V8 is slow", "a file read is waiting in the pool", and "a binding changes values" are different claims. Each one needs its own proof.
Practice exercises
What does this print in Node? Write the exact output, with the space between the two values.
console.log(
Object.keys(process.versions).includes("v8"),
Object.keys(process.versions).includes("uv"),
);It prints true true. The name v8 is for the V8 engine, and uv is for libuv. Node has both.
Which part of Node passes a JavaScript call, such as fs.readFile, to the C++ code that does the work?
The C++ binding. It changes the JavaScript call into a request for C++ code, then changes the result back into JavaScript values.
You are writing an addon. You don't want to rebuild it each time V8 changes in a new Node version. Which set of C functions should you look at first?
Node-API. An addon that only uses Node-API keeps working in newer Node versions without a rebuild.
Which built-in module has the list the example used to check for fs and worker_threads?
node:module. Its builtinModules list holds the name of every module that comes with Node.
A file job on a website's server feels slow. What should you find out first, before you blame V8, libuv, or the thread pool?
First find which Node function the code calls, and how it waits for its work. Then measure where the time goes, before you pick a tool for files, DNS, the network, or heavy math.
Which flag tells Node to run a script and save the result as a startup snapshot blob?
--build-snapshot. It runs a script and saves the result in a snapshot blob.
Check your understanding
For each question, think about the job of each part. Not all Node work is the same kind of work.
Question 1 of 7What does this version check print in Node?
Read the code, then predictJavaScriptconsole.log(Object.keys(process.versions).includes("v8"), Object.keys(process.versions).includes("uv"));Choose an answer to see the explanation.
Question 2 of 7Which part of Node runs your JavaScript?
Choose an answer to see the explanation.
Question 3 of 7What is a C++ binding for?
Choose an answer to see the explanation.
Question 4 of 7What does Node-API promise for an addon that only uses Node-API?
Choose an answer to see the explanation.
Question 5 of 7What does this list of steps print?
Read the code, then predictconst layers = ["JavaScript API", "C++ binding", "callback"]; console.log(layers.length);Choose an answer to see the explanation.
Question 6 of 7Why does Node's own code use primordials?
Choose an answer to see the explanation.
Question 7 of 7What does
--snapshot-blobdo without--build-snapshot?Choose an answer to see the explanation.
Key takeaways
- V8 runs JavaScript. Node adds tools around it for files, networks, and timers.
- libuv runs the event loop and talks to the operating system. Bindings connect JavaScript functions to C++ code.
- An addon that only uses Node-API keeps working in newer Node versions. Direct use of V8 or libuv does not get this promise.
- Built-in modules, such as
fs, come with Node. Primordials are Node's own saved copies of built-in methods. - A startup snapshot saves setup work, so Node can start faster on a matching computer.
Remember the one-liner.
Node.js is V8 plus libuv, C++ bindings, and modules, which let your JavaScript reach files, networks, and timers.
Coming next: Inside Deno, Bun & edge runtimes. You will see how other runtimes pick a different engine and different tools.