In Module 1 you used Node.js as a black box: you typed node src/catalog.js and events showed up on screen. It worked, but you have no idea what is inside that box. And that ignorance has very concrete consequences: you could not explain why a Node server holds ten thousand simultaneous connections with a single process, why reading a large file blocks nobody but computing a hash does, or why the advice "don't block the main thread" is repeated ad nauseam throughout the documentation.
This lesson opens the box. We are going to look at the real layers that make up Node.js — your JavaScript, the standard library, the bindings, V8 and libuv — what each one does exactly, and the nuance that almost everybody gets wrong: Node.js is not entirely single-threaded. There is a main thread where your code lives and a group of auxiliary threads where things happen that you never see. By the end you will know which operations go where, you will be able to measure it with runnable experiments, and you will understand why blocking the main thread is the cardinal sin of Node development.
It is the foundation everything else in this module rests on. Without it, the event loop of the next lesson is magic; with it, it is mechanics.
Contents
- The Node.js stack layer by layer
- V8: the engine that runs your JavaScript
- libuv: the engine that makes asynchrony possible
- The key nuance: Node is not entirely single-threaded
- What goes to the thread pool and what does not
- A measurable demo: four threads and a fifth one waiting
- The cardinal sin: blocking the main thread
- The life cycle of a Node process
- Inspecting the process:
process.versionandprocess.memoryUsage()
- The Node.js stack layer by layer
When you run node src/catalog.js, that command starts a program written in C++ that contains, bundled inside the same binary, a JavaScript engine and an asynchronous I/O library. Your .js file is simply the input data for that program.
These are the layers, from top to bottom:
flowchart TD
A["<b>Your JavaScript code</b><br/>src/catalog.js, src/domain/event.js"]
B["<b>Node standard library</b> (JavaScript)<br/>fs, http, path, events, crypto...<br/>lib/*.js inside the binary"]
C["<b>Bindings</b> (C++)<br/>The bridge between JavaScript and C++<br/>node_file.cc, node_http_parser.cc..."]
D1["<b>V8</b> (C++)<br/>Runs JavaScript:<br/>compiles, manages memory"]
D2["<b>libuv</b> (C)<br/>Event loop, thread pool,<br/>asynchronous system I/O"]
D3["<b>Other libraries</b> (C/C++)<br/>OpenSSL, zlib, c-ares, llhttp"]
E["<b>Operating system</b><br/>epoll (Linux) · kqueue (macOS) · IOCP (Windows)"]
A --> B
B --> C
C --> D1
C --> D2
C --> D3
D1 --> E
D2 --> E
D3 --> E
Let's go layer by layer with a concrete example: the fs.readFile('data/events.json', callback) call you will actually write in Module 3.
| Layer | What it is | What it does with fs.readFile |
|---|---|---|
| Your code | The .js files you write |
Calls fs.readFile with a path and a callback |
| Standard library | Node modules written in JavaScript, precompiled inside the binary | Validates the arguments, normalizes the path, prepares the request object and calls the binding |
| Bindings | C++ code that exposes native functions to JavaScript | Translates JavaScript values into C++ types and calls uv_fs_read |
| libuv | C library for asynchronous I/O | Queues the read on its thread pool and, when it finishes, signals back |
| V8 | JavaScript engine | Runs your callback when Node asks it to |
| Operating system | The kernel | Performs the actual read from disk |
Two important ideas come out of this table:
- A good chunk of Node.js is written in JavaScript, not in C++. The
fs,http,path,events,stream… modules are.jsfiles embedded in the binary. You can read them: they live in the Node repository under thelib/folder. This means Node's standard library is, to a large extent, code just like yours. - The bindings are the border. Everything JavaScript cannot do by itself — open a socket, read a file, encrypt — crosses that border into C++. Crossing it has a small but non-zero cost, and that is why you want to make a few big calls instead of many small ones.
Quick check. Node exposes which version of each dependency it carries inside. Run this in your terminal:
node -p "JSON.stringify(process.versions, null, 2)"You will see something like
{ "node": "22.11.0", "v8": "12.4.254.21", "uv": "1.48.0", "openssl": "3.0.15", "zlib": "1.3.1", ... }. Each of those lines is one of the libraries in the diagram.
- V8: the engine that runs your JavaScript
V8 is Google's JavaScript engine, the same one Chrome uses. It has a single job: turn your JavaScript code into machine instructions and run them. V8 knows nothing about files, or networks, or timers. That is not part of JavaScript; Node adds it.
V8 does three things that affect you directly.
2.1 JIT (just-in-time) compilation
JavaScript is not interpreted line by line the way it was in the nineties. V8 uses a multi-stage pipeline:
- Parsing and bytecode. An interpreter called Ignition turns your code into compact bytecode and executes it. It starts fast but runs at a moderate speed.
- Observation. While it runs, V8 records which functions are called a lot and with which data types.
- Optimization. An optimizing compiler (TurboFan) takes the "hot" functions and generates machine code specialized for the types it has observed.
- Deoptimization. If you suddenly call that function with a type other than the expected one, V8 throws away the optimized code and falls back to bytecode.
The practical consequence for you: type consistency matters. A function that sometimes receives a number and sometimes a string is slower than one that always receives a number.
// src/lab/types.js
// Good: priceCents is ALWAYS an integer.
function calculateTotalCents(sessions) {
return sessions.reduce((total, s) => total + s.sold * s.priceCents, 0);
}
// Bad: if priceCents sometimes arrives as a string ('2500'), V8 cannot
// specialize the function and, on top of that, '+' concatenates instead of adding.This is another reason — beyond the exactness of money — for our convention of always storing amounts as integer cents: every field has a single type for its whole life.
2.2 The heap and memory
V8 manages the memory of all your objects in an area called the heap, split into two regions:
| Region | What it holds | How it is cleaned |
|---|---|---|
| Young generation (new space) | Freshly created objects. It is small (a few MB) | Very frequent and very fast collection (scavenge) |
| Old generation (old space) | Objects that have survived several collections | Less frequent and more expensive collection (mark-sweep-compact) |
The hypothesis this design is based on is that most objects die young. In Escena Viva, the temporary object you create to format a date dies in microseconds; the catalog array lives for the whole process. Separating them makes cleaning the ephemeral stuff very cheap.
2.3 Garbage collection
In JavaScript you do not free memory by hand: V8 detects which objects are no longer reachable from any live variable and reclaims their space. What you need to know:
- Garbage collection pauses the execution of your JavaScript. Young-generation pauses are microseconds long and go unnoticed; old-generation ones can be milliseconds long and are definitely noticeable on a loaded server.
- The heap has a limit. By default it is around 4 GB on modern 64-bit builds, and it is adjusted with
node --max-old-space-size=2048. If you exceed it, the process dies withJavaScript heap out of memory. - You can leak memory in JavaScript. Not by forgetting to free it, but by still referring to things you no longer need: a cache that grows without bound, an array that only accumulates, or — a very typical case that we will see in the Events and EventEmitter lesson — listeners that get registered and never unregistered.
- libuv: the engine that makes asynchrony possible
If V8 brings the "run JavaScript" part, libuv brings the "do it without waiting" part. It is a library written in C, born precisely for Node.js, whose goal is to offer a single cross-platform asynchronous API on top of operating system mechanisms that are completely different from one another.
libuv has three responsibilities:
- The event loop. The infinite cycle that asks "is anything finished? is there any callback to run?" and works through the pending work. It is so central that we devote the entire next lesson to it, The Event Loop.
- The operating system's asynchronous I/O. For networking, libuv uses each platform's native notification mechanism:
epollon Linux,kqueueon macOS and BSD, event ports on Solaris and IOCP on Windows. With these mechanisms, a single thread can watch thousands of sockets at once without burning one thread per connection. - The work queue and the thread pool. For operations the operating system does not offer asynchronously in a reliable way, libuv keeps a group of real threads it sends work to. It is the part that causes the most misunderstandings and the one we will look at in detail next.
flowchart LR
JS["Your JavaScript<br/>(main thread)"] -->|"async request"| UV["libuv"]
UV -->|"network, sockets, pipes"| OS["epoll / kqueue / IOCP<br/>(no extra threads)"]
UV -->|"files, DNS, crypto, zlib"| TP["Thread pool<br/>4 threads by default"]
OS -->|"ready"| CB["Callback queue"]
TP -->|"ready"| CB
CB -->|"the loop runs them"| JS
Read the diagram out loud once: your code asks for something, libuv decides which route to resolve it by, the work happens outside your thread, and when it finishes, the result comes back to your thread as a callback. That round trip is the whole of Node's asynchrony.
- The key nuance: Node is not entirely single-threaded
The phrase "Node.js is single-threaded" is repeated everywhere and it is half true. The precise version is:
Your JavaScript code runs on a single thread. The input/output work does not.
If you open a process monitor while a Node program is running, you will see the process has several threads: the main one, the four in the thread pool, and a few more from V8 for garbage collection and background compilation.
Check it for yourself:
# Start a Node process that never ends
node -e "setInterval(() => {}, 1000)" &
# Linux: count the process threads
ps -o nlwp -p $!
# NLWP
# 11 <- eleven threads, not oneOn macOS you can use ps -M <pid>; on Windows, Task Manager with the "Threads" column enabled.
What this means in practice:
| Claim | True? | Nuance |
|---|---|---|
| "Node runs my JavaScript on a single thread" | Yes | Two of your functions never run at the same time. That is why there are no race conditions on variables |
| "Node is single-threaded, full stop" | No | The process has a thread pool and internal V8 threads |
| "Node can use all 8 cores of my machine" | Partially | I/O can; your JavaScript cannot. That is what cluster and worker_threads from Module 10 are for |
| "If I block the main thread, only that request slows down" | No | Every request in that process is blocked |
The advantage of your JavaScript being single-threaded is enormous and often forgotten: there are no race conditions inside a block of synchronous code. When you write session.sold += quantity in Escena Viva, nobody can sneak in between the read and the write. In Java or Go you would have to think about a lock; here you don't. The price of that peace of mind is that you are responsible for handing control back quickly.
- What goes to the thread pool and what does not
This is probably the most useful table in the lesson. The libuv thread pool has 4 threads by default and is configured with the UV_THREADPOOL_SIZE environment variable (maximum 1024 in modern versions).
| Operation | Route | Why |
|---|---|---|
| TCP/UDP sockets, HTTP server and client | epoll/kqueue/IOCP | The operating system already offers native asynchronous notification for networking |
| Pipes and child processes | epoll/kqueue/IOCP | Same as networking |
fs.readFile, fs.writeFile, fs.stat… |
Thread pool | The system's asynchronous file I/O is inconsistent across platforms; libuv simulates it with threads |
crypto.pbkdf2, crypto.scrypt, crypto.randomBytes (asynchronous version) |
Thread pool | They are pure CPU work; on the main thread they would block it |
zlib.gzip, zlib.deflate (asynchronous versions) |
Thread pool | Compression: also compute intensive |
dns.lookup |
Thread pool | It uses the system's getaddrinfo, which is a blocking call |
dns.resolve, dns.resolve4… |
epoll/kqueue/IOCP | It uses the c-ares library, which speaks DNS over the network asynchronously |
setTimeout, setInterval, setImmediate |
Neither one | The event loop itself manages them, with no threads and no I/O |
Your for loop over 3000 sessions |
Main thread | It is JavaScript. Nobody is going to move it anywhere else |
Notice the DNS detail: dns.lookup and dns.resolve are not synonyms. dns.lookup is what http.request uses under the hood when you give it a host name, and it consumes a thread pool thread. It is a classic: an application that makes many outbound HTTP requests exhausts the four threads with DNS resolutions and, all of a sudden, file reads become slow for no apparent reason.
And notice the last row, because it sums up the lesson: for computation in JavaScript there is no way out. The thread pool only serves Node's native code. If you write a loop that takes two seconds, the main thread pays for those two seconds.
- A measurable demo: four threads and a fifth one waiting
Enough theory. Let's measure the thread pool. We will use crypto.pbkdf2, a key derivation function (the same family we will use in Module 8 for passwords) that is CPU intensive and that libuv sends to the thread pool.
Create src/lab/thread-pool.js:
// src/lab/thread-pool.js
// Shows that the libuv thread pool has 4 threads by default.
// We launch N identical CPU tasks and measure when each one finishes.
const crypto = require('node:crypto');
// How many tasks to launch: 5 by default, or the first command-line argument.
const TASKS = Number(process.argv[2]) || 5;
// pbkdf2 parameters: password, salt, iterations, length, algorithm.
// 200000 iterations take on the order of 100-200 ms on a normal machine.
const ITERATIONS = 200000;
const start = Date.now();
console.log(`Thread pool: ${process.env.UV_THREADPOOL_SIZE || 4} threads`);
console.log(`Launching ${TASKS} CPU tasks...`);
console.log('');
for (let i = 1; i <= TASKS; i++) {
crypto.pbkdf2('ticket-escena-viva', 'salt', ITERATIONS, 64, 'sha512', () => {
const elapsed = Date.now() - start;
console.log(`Task ${i} finished in ${elapsed} ms`);
});
}Run it:
Typical output (the milliseconds will vary depending on your machine, but the pattern will be the same):
Thread pool: 4 threads Launching 5 CPU tasks... Task 1 finished in 168 ms Task 2 finished in 171 ms Task 3 finished in 172 ms Task 4 finished in 175 ms Task 5 finished in 338 ms <-- twice as long
Read that result carefully, because it contains the whole lesson:
- The first four tasks finish almost together, at around 170 ms. They ran truly in parallel, each one on a thread pool thread, on four different cores of your processor.
- The fifth takes roughly twice as long. There was no fifth free thread: it had to wait in the queue until one of the four was released.
Now raise the size of the thread pool and run it again:
# Linux and macOS
UV_THREADPOOL_SIZE=5 node src/lab/thread-pool.js 5
# Windows (PowerShell)
# $env:UV_THREADPOOL_SIZE=5; node src/lab/thread-pool.js 5Thread pool: 5 threads Launching 5 CPU tasks... Task 1 finished in 178 ms Task 2 finished in 181 ms Task 3 finished in 182 ms Task 4 finished in 184 ms Task 5 finished in 185 ms <-- now in parallel too
All five finish together. You have just verified the size of the thread pool and its effect experimentally.
Beware the temptation to raise it a lot.
UV_THREADPOOL_SIZE=128does not multiply performance by 32: if your machine has 8 cores, 128 threads compete for those same 8 cores and add context-switching cost. The sensible rule is to bring it close to the number of cores when your application does a lot of file I/O or a lot ofcrypto, and always measure before and after. The variable must be set before starting the process: changing it from the inside withprocess.env.UV_THREADPOOL_SIZE = 8has no effect, because the pool has already been created.
- The cardinal sin: blocking the main thread
Now the complementary experiment, and the most important one in the lesson. What happens if you do the heavy work in JavaScript instead of delegating it?
Create src/lab/blocking.js:
// src/lab/blocking.js
// Shows that a synchronous loop freezes the WHOLE process.
const start = Date.now();
// A timer that should fire every 100 ms.
// We use it as the process's "electrocardiogram".
const heartbeat = setInterval(() => {
console.log(` heartbeat at ${Date.now() - start} ms`);
}, 100);
// We stop the experiment after 2 seconds of wall-clock time.
setTimeout(() => {
clearInterval(heartbeat);
console.log('Done.');
}, 2000);
// Heavy synchronous task: spin for a given number of milliseconds.
// It simulates a badly designed computation over the catalog.
function blockFor(ms) {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
// Empty loop on purpose: it stands for any long computation.
}
}
// At 300 ms, we block the main thread for a whole second.
setTimeout(() => {
console.log('>> The 1000 ms block starts');
blockFor(1000);
console.log('>> The block ends');
}, 300);Run node src/lab/blocking.js:
heartbeat at 103 ms heartbeat at 205 ms >> The 1000 ms block starts >> The block ends heartbeat at 1306 ms <-- the beats at 400, 500, 600... never arrived heartbeat at 1406 ms heartbeat at 1507 ms ... Done.
Look at the gap: between 205 ms and 1306 ms there was not a single heartbeat. For a whole second the process was alive but completely deaf. Timers did not fire, and had it been an HTTP server, no request would have been served: they would have sat in the operating system's queue waiting for the main thread to become free again.
Translated into Escena Viva: if one user asks for a report that takes a second to compute synchronously, the other three hundred users buying tickets at that moment wait that second too. It is not that their request is slower; it is that the entire server has stopped.
This is the fundamental difference from the thread-per-request model you saw in the first lesson: there, a slow request only harms whoever made it. Here it harms everybody. It is the price of the efficiency of Node's model, and it explains the rule that will govern the rest of the course:
Everything you do on the main thread must be short. Whatever takes long, delegate it.
The ways to delegate, in order of preference:
| Situation | Solution | Where you'll see it |
|---|---|---|
| I/O: files, network, database | Use the asynchronous version of the API, never the ...Sync one |
Modules 3, 4 and 7 |
| Heavy computation Node already offers natively | crypto, zlib in their asynchronous version (they go to the thread pool) |
This very module |
| Heavy computation in your own JavaScript | worker_threads: a separate thread with its own V8 |
Module 10 |
| Using every core to serve traffic | cluster: several Node processes sharing the port |
Module 10 |
| Work that can wait (emails, PDFs) | Move it out of the process: a job queue | Module 10 |
This is exactly the promise made in the What Is Node.js? lesson when we said CPU-intensive computation was Node's weak point and that Module 10 would solve it. Now you know why it is the weak point.
- The life cycle of a Node process
One question remains that you may not have asked yourself: when you ran node src/catalog.js, the program printed the catalog and ended on its own. But if you run node -e "setInterval(() => {}, 1000)", the process never ends. Who decides?
The life cycle of a Node process has these stages:
flowchart TD
A["1. Startup<br/>The binary initializes V8 and libuv"] --> B["2. Bootstrap<br/>process, console and the<br/>internal modules are prepared"]
B --> C["3. Script execution<br/>Your file runs top to bottom,<br/>synchronously and completely"]
C --> D{"4. Any pending<br/>tasks left?"}
D -->|"Yes"| E["5. Event loop<br/>Wait, run callbacks,<br/>ask again"]
E --> D
D -->|"No"| F["6. Exit<br/>'exit' is emitted and the process<br/>returns process.exitCode"]
The key is the diamond in step 4. libuv keeps a reference count of the active tasks: scheduled timers, open sockets, in-flight file operations, listening servers. As long as that count is greater than zero, the loop keeps spinning. When it reaches zero, there is nothing left that could ever happen, so Node shuts down cleanly and hands control back to the terminal.
This explains both cases:
// src/lab/lifecycle.js
console.log('1. The script starts');
// This ADDS a reference: there is a pending future task.
setTimeout(() => {
console.log('3. The 500 ms timer fired');
// Once it runs, the reference is released. Nothing is left -> exit.
}, 500);
console.log('2. The script ends (but the process is still alive)');
// The 'exit' event is emitted right before dying.
process.on('exit', (code) => {
// Only SYNCHRONOUS code can run here: the loop is already closed.
console.log(`4. Exiting with code ${code}`);
});node src/lab/lifecycle.js
# 1. The script starts
# 2. The script ends (but the process is still alive)
# 3. The 500 ms timer fired
# 4. Exiting with code 0A setInterval, by contrast, never releases its reference because there is always a next run scheduled: the process lives forever until somebody calls clearInterval or you kill it with Ctrl+C. The same happens with a listening HTTP server, and that is exactly what we want: a server that shut itself down would not be much use.
There is an escape hatch, unref(), which marks a task as "does not count towards keeping the process alive":
// This timer does NOT prevent the process from ending.
const watcher = setInterval(() => {
console.error(`Memory: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)} MB`);
}, 1000);
watcher.unref();It is the usual pattern for watchdog and metrics tasks: you want them to report while the application is alive, but not to keep it alive.
And finally, forced exit:
| Form | Effect |
|---|---|
| Pending tasks run out | Natural, clean exit. The normal case |
process.exitCode = 1 |
Marks the exit code and lets the process finish normally. The correct way to signal an error |
process.exit(1) |
Cuts off immediately: half-finished writes are lost, pending callbacks never run. Use only in extreme cases |
| Uncaught exception | The process dies with code 1 and a stack trace on stderr |
You already used process.exitCode in the Your First Node.js Program lesson; now you know why it is preferred over process.exit().
- Inspecting the process:
process.version and process.memoryUsage()
process.version and process.memoryUsage()Node lets you look inside itself at run time. Two tools you will use constantly.
9.1 Versions
// src/lab/diagnostics.js
console.log(`Node.js : ${process.version}`); // v22.11.0
console.log(`V8 : ${process.versions.v8}`); // 12.4.254.21
console.log(`libuv : ${process.versions.uv}`); // 1.48.0
console.log(`OpenSSL : ${process.versions.openssl}`);
console.log(`Platform : ${process.platform} ${process.arch}`); // linux x64
console.log(`Cores : ${require('node:os').cpus().length}`);
console.log(`PID : ${process.pid}`);process.version (singular, with a leading v) is the Node version; process.versions (plural) is the object with every dependency. They get mixed up constantly.
A real use: checking at startup that the minimum version the project needs is met.
// src/utils/check-version.js
// Aborts if the Node version is older than the minimum supported one.
const MIN_MAJOR = 20;
// process.version is 'v22.11.0'; we drop the 'v' and keep the first number.
const currentMajor = Number(process.version.slice(1).split('.')[0]);
if (currentMajor < MIN_MAJOR) {
console.error(
`Escena Viva requires Node.js ${MIN_MAJOR} or newer. ` +
`You have ${process.version}. Run "nvm use" at the project root.`
);
process.exitCode = 1;
} else {
console.log(`Node version is correct: ${process.version}`);
}Remember the project convention: diagnostics go to stderr (console.error), data goes to stdout.
9.2 Memory
process.memoryUsage() returns an object with the current consumption, in bytes:
// src/lab/memory.js
// Measures how much memory it takes to load a large catalog into memory.
function inMegabytes(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function reportMemory(label) {
const m = process.memoryUsage();
console.log(
`${label.padEnd(22)} rss=${inMegabytes(m.rss).padStart(9)} ` +
`heapTotal=${inMegabytes(m.heapTotal).padStart(9)} ` +
`heapUsed=${inMegabytes(m.heapUsed).padStart(9)}`
);
}
reportMemory('At startup');
// We simulate a full year's catalog: 3000 sessions with data.
const sessions = [];
for (let i = 1; i <= 3000; i++) {
sessions.push({
id: `ses-${String(Math.ceil(i / 3)).padStart(3, '0')}-${(i % 3) + 1}`,
dateTime: '2026-10-03T20:00:00',
capacity: 420,
sold: i % 420,
priceCents: 2500
});
}
reportMemory('With 3000 sessions');
// We release the reference: the objects become collectable garbage.
sessions.length = 0;
reportMemory('After emptying array');At startup rss= 42.3 MB heapTotal= 5.6 MB heapUsed= 4.4 MB With 3000 sessions rss= 45.1 MB heapTotal= 8.4 MB heapUsed= 5.7 MB After emptying array rss= 45.1 MB heapTotal= 8.4 MB heapUsed= 5.7 MB
What each field means:
| Field | What it measures | When to look at it |
|---|---|---|
rss |
Resident Set Size: total physical memory of the process, including Node's own binary | To know how much RAM your container should request in production |
heapTotal |
V8 heap reserved | Compare with heapUsed to see the headroom |
heapUsed |
V8 heap actually in use by your objects | The key metric for spotting memory leaks |
external |
Memory of C++ objects tied to JavaScript (buffers, sockets) | When working with Buffer (Module 3) |
arrayBuffers |
The part of external corresponding to ArrayBuffer |
Binary data |
Notice the third line of the example: memory did not go down when we emptied the array. That is not a bug. Garbage collection is not immediate: V8 will reclaim that space when it suits it, not when you stop using the objects. That is why, to diagnose leaks, never look at an isolated measurement: look at the trend of heapUsed over minutes or hours. A line that goes up and down is healthy; one that only goes up is a leak.
In Module 11 we will turn this manual measurement into continuously published metrics.
Common Mistakes and Tips
Mistake 1: believing Node is literally single-threaded and therefore cannot use several cores.
Half true. Your JavaScript is single-threaded, but file I/O, DNS and crypto do use several cores through the thread pool. The only thing that is not parallelized is your code.
Mistake 2: using the ...Sync versions in a server.
fs.readFileSync in a one-shot script is perfectly reasonable. Inside an HTTP request handler it is a serious mistake: it blocks every user while the disk responds.
Mistake 3: cranking UV_THREADPOOL_SIZE up blindly.
More threads than cores creates contention and context switches. Measure before touching it, and remember it has to be set before starting the process.
Mistake 4: confusing process.version with process.versions.
The first is a string ('v22.11.0'), the second an object. process.versions.node is equivalent to the first one but without the v.
Mistake 5: calling process.exit() to "finish properly".
It cuts the process off dead. If a file was being written or an HTTP response was half sent, they are lost. Use process.exitCode and let the loop finish on its own.
Mistake 6: diagnosing a memory leak from a single measurement.
heapUsed goes up and down constantly because of how the collector itself works. Only a sustained trend is a sign of a leak.
Tip 1: memorize the question. For any operation, ask yourself: "is this I/O or is this computation?". If it is I/O, there is an asynchronous version and you must use it. If it is a long computation in JavaScript, there is no magic: either you optimize it, or you move it to a worker, or you take it out of the process.
Tip 2: keep the heartbeat script handy. The setInterval that prints every 100 ms is the cheapest way to find out whether something is blocking your process. In the next lesson we will turn it into a formal measurement of event loop lag.
Tip 3: read Node's source code. Since a good part of the standard library is JavaScript, lib/fs.js or lib/events.js in the Node repository are perfectly readable and teach a great deal.
Exercises
Exercise 1: find the optimal thread pool size
Extend src/lab/thread-pool.js so that, on top of what it already does, it prints a summary at the end: the total time, the number of tasks and the average time per task. Then run the script with 8 tasks and with these values of UV_THREADPOOL_SIZE: 1, 2, 4, 8 and 16.
Fill in a table with the total times, explain the shape of the curve and answer this: why does going from 8 to 16 not improve things (and probably makes them worse)?
Hint: require('node:os').cpus().length tells you how many cores your machine has. The answer depends on that number.
Exercise 2: the report that freezes Escena Viva
Write src/lab/blocking-report.js, simulating what happens on the Escena Viva server during a festival's on-sale opening:
- A
setIntervalevery 50 ms that printsrequest served at T ms(simulating users buying). - At 500 ms, a synchronous
calculateGlobalOccupancy()function that walks an array of 3,000 sessions and, for each one, performs an artificially expensive computation (for example, 200,000 iterations of a mathematical operation) so the total takes around one second. - The process must stop after 3 seconds.
Run it, count how many requests were lost during the block and work out how many users would have been affected if the server handled 200 requests per second.
Exercise 3: a startup diagnostic for Escena Viva
Write src/utils/diagnostics.js, a diagnostics module the application will run at startup and that prints to stderr a block with:
- Node, V8 and libuv versions.
- Platform, architecture, number of cores and
pid. - The effective thread pool size.
rssandheapUsedmemory in MB with one decimal.- A warning if Node's major version is lower than 20, with
process.exitCode = 1. - A warning if the number of cores is 1, pointing out that
clusterwill add nothing (a preview of Module 10).
It must work as-is with node src/utils/diagnostics.js.
Solutions
Solution 1
// src/lab/thread-pool.js
const crypto = require('node:crypto');
const os = require('node:os');
const TASKS = Number(process.argv[2]) || 5;
const ITERATIONS = 200000;
const THREADS = Number(process.env.UV_THREADPOOL_SIZE) || 4;
const start = Date.now();
let finished = 0;
console.error(`Cores: ${os.cpus().length} | Thread pool: ${THREADS} | Tasks: ${TASKS}`);
for (let i = 1; i <= TASKS; i++) {
crypto.pbkdf2('ticket-escena-viva', 'salt', ITERATIONS, 64, 'sha512', () => {
const elapsed = Date.now() - start;
console.log(`Task ${String(i).padStart(2)} finished in ${elapsed} ms`);
finished++;
if (finished === TASKS) {
const total = Date.now() - start;
console.log('');
console.log(`Total : ${total} ms`);
console.log(`Avg/task : ${Math.round(total / TASKS)} ms`);
// Theoretical rounds: how many full passes of the pool were needed.
console.log(`Rounds : ${Math.ceil(TASKS / THREADS)}`);
}
});
}for n in 1 2 4 8 16; do
echo "--- UV_THREADPOOL_SIZE=$n ---"
UV_THREADPOOL_SIZE=$n node src/lab/thread-pool.js 8 | tail -4
doneTypical results on an 8-core machine:
UV_THREADPOOL_SIZE |
Rounds | Approximate total | Comment |
|---|---|---|---|
| 1 | 8 | ~1400 ms | Everything serialized: 8 × 175 ms |
| 2 | 4 | ~700 ms | Half of it |
| 4 | 2 | ~350 ms | The default value |
| 8 | 1 | ~190 ms | Optimal: a single round, one thread per core |
| 16 | 1 | ~210 ms | Slightly worse |
The curve drops almost proportionally until it matches the number of cores and then flattens out or gets worse. The reason is that these tasks are pure CPU: with 8 cores you can only truly run 8 simultaneous computations. With 16 threads, the operating system splits those same 8 cores among 16 threads by alternating them, and that sharing has a cost (context switching, cache invalidation) with no gain whatsoever. More threads only help when the threads wait instead of computing, which is the case for slow file I/O, not for pbkdf2.
Solution 2
// src/lab/blocking-report.js
// Simulates the effect of a long synchronous computation on Escena Viva's traffic.
const start = Date.now();
let served = 0;
// Every 50 ms we serve a purchase request.
const traffic = setInterval(() => {
served++;
console.log(`request ${String(served).padStart(3)} served at ${Date.now() - start} ms`);
}, 50);
// Large catalog: 3000 sessions.
const sessions = [];
for (let i = 1; i <= 3000; i++) {
sessions.push({ id: `ses-${i}`, capacity: 420, sold: i % 420, priceCents: 2500 });
}
// Deliberately expensive computation: 200000 operations per session.
function calculateGlobalOccupancy(sessions) {
let accumulated = 0;
for (const session of sessions) {
let noise = 0;
for (let i = 0; i < 200000; i++) {
noise += Math.sqrt(i); // Artificial work: it stands for a badly designed real computation.
}
accumulated += session.sold / session.capacity + noise * 0;
}
return Math.round((accumulated / sessions.length) * 100);
}
setTimeout(() => {
const t0 = Date.now();
console.error('>> The synchronous report starts');
const occupancy = calculateGlobalOccupancy(sessions);
const duration = Date.now() - t0;
console.error(`>> Report finished: ${occupancy}% occupancy, ${duration} ms of blocking`);
console.error(`>> Lost requests: ~${Math.floor(duration / 50)}`);
}, 500);
setTimeout(() => {
clearInterval(traffic);
console.error(`Total requests served in 3 s: ${served} (should be ~60)`);
}, 3000);Abridged output:
request 1 served at 51 ms ... request 9 served at 452 ms >> The synchronous report starts >> Report finished: 50% occupancy, 1180 ms of blocking >> Lost requests: ~23 request 10 served at 1683 ms ... Total requests served in 3 s: 37 (should be ~60)
Analysis: about 23 slots of 50 ms were lost. If the real server handled 200 requests per second, a block of 1.18 seconds would have left roughly 236 users waiting, every one of them with a spinner turning in the browser even though their purchase had nothing to do with the report. And it is not that they were delayed a little: they were delayed more than a second, which during an on-sale opening is the difference between getting a ticket and not getting one.
The solution is not "optimize the report" (although that helps), but take it off the main thread: a worker_thread (Module 10) or a queued job that computes it elsewhere and leaves the result in a cache.
Solution 3
// src/utils/diagnostics.js
// Escena Viva startup diagnostics.
// Everything goes to stderr: this is operational information, not application data.
const os = require('node:os');
const MIN_MAJOR = 20;
function inMegabytes(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function runDiagnostics() {
const memory = process.memoryUsage();
const cores = os.cpus().length;
const poolThreads = Number(process.env.UV_THREADPOOL_SIZE) || 4;
const currentMajor = Number(process.version.slice(1).split('.')[0]);
console.error('=========================================');
console.error(' ESCENA VIVA - STARTUP DIAGNOSTICS');
console.error('=========================================');
console.error(` Node.js : ${process.version}`);
console.error(` V8 : ${process.versions.v8}`);
console.error(` libuv : ${process.versions.uv}`);
console.error(` Platform : ${process.platform} ${process.arch}`);
console.error(` Cores : ${cores}`);
console.error(` Thread pool : ${poolThreads} threads`);
console.error(` PID : ${process.pid}`);
console.error(` Memory rss : ${inMegabytes(memory.rss)}`);
console.error(` Heap used : ${inMegabytes(memory.heapUsed)}`);
console.error('-----------------------------------------');
if (currentMajor < MIN_MAJOR) {
console.error(` WARNING: Node ${MIN_MAJOR}+ is required. Run "nvm use".`);
process.exitCode = 1;
} else {
console.error(' Node version: correct.');
}
if (cores === 1) {
console.error(' WARNING: a single core. The cluster module will bring no gain.');
}
console.error('=========================================');
}
runDiagnostics();Details of the solution worth noting:
- Everything on
stderr. That way you can runnode src/catalog.js > catalog.txtand the diagnostics will still show on screen without polluting the data file. process.exitCodeinstead ofprocess.exit(), consistent with the project convention.- The single-core warning is real: in a container with
--cpus=1, starting 4 processes withclustermakes performance worse rather than better.
In the CommonJS Modules and require() lesson we will turn this file into a reusable module that exports the runDiagnostics function instead of running it on load. Right now it does both things, which is precisely the bad practice we will learn to avoid there.
Conclusion
You have opened the box. Node.js is not just "JavaScript on the server": it is a C++ program that combines V8, the engine that compiles and runs your JavaScript with JIT and manages the heap and garbage collection, with libuv, the C library that provides the event loop, the operating system's asynchronous I/O and a thread pool. Between the two of them and your code there are two more layers: the standard library — largely written in JavaScript — and the C++ bindings that act as the border.
You have corrected the most repeated cliché in the ecosystem: Node is not entirely single-threaded. Your JavaScript does run on a single thread — and that is where the comfort of having no race conditions comes from — but file reads, dns.lookup resolutions, cryptography and compression travel to a four-thread thread pool configurable with UV_THREADPOOL_SIZE, while networking uses epoll, kqueue or IOCP directly without consuming threads. And you did not just take it on faith: you measured it with crypto.pbkdf2, watching four tasks finish together and the fifth wait its turn.
You have also seen the cardinal sin in action. A one-second synchronous loop left the process deaf for a whole second: no heartbeats, no timers and — on a real server — nobody served. That is the reason a heavy report in Escena Viva cannot be computed inside a request, and the reason Module 10 exists. Finally, you understand why a script ends on its own: libuv counts the pending tasks and, when the count reaches zero, the process emits exit and dies; a setInterval or a listening server keep that count alive on purpose, and unref() is the escape hatch.
One piece remains that we have mentioned in every section without developing: the event loop. You know it exists, that it spins, that it runs callbacks and that it counts references, but you do not know in what order it does things. And that order explains everything: why setTimeout(fn, 0) is not immediate, why setImmediate sometimes goes before a timer and sometimes after, and why process.nextTick cuts in front of everyone. In the next lesson, The Event Loop, we will walk through its six phases one by one until you can predict, line by line, the output order of any asynchronous program.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
