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

  1. The Node.js stack layer by layer
  2. V8: the engine that runs your JavaScript
  3. libuv: the engine that makes asynchrony possible
  4. The key nuance: Node is not entirely single-threaded
  5. What goes to the thread pool and what does not
  6. A measurable demo: four threads and a fifth one waiting
  7. The cardinal sin: blocking the main thread
  8. The life cycle of a Node process
  9. Inspecting the process: process.version and process.memoryUsage()

  1. 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 .js files embedded in the binary. You can read them: they live in the Node repository under the lib/ 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.

  1. 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:

  1. 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.
  2. Observation. While it runs, V8 records which functions are called a lot and with which data types.
  3. Optimization. An optimizing compiler (TurboFan) takes the "hot" functions and generates machine code specialized for the types it has observed.
  4. 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 with JavaScript 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.

  1. 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:

  1. 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.
  2. The operating system's asynchronous I/O. For networking, libuv uses each platform's native notification mechanism: epoll on Linux, kqueue on 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.
  3. 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.

  1. 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 one

On 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.

  1. 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.

  1. 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:

node src/lab/thread-pool.js 5

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 5
Thread 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=128 does 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 of crypto, and always measure before and after. The variable must be set before starting the process: changing it from the inside with process.env.UV_THREADPOOL_SIZE = 8 has no effect, because the pool has already been created.

  1. 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.

  1. 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 0

A 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().

  1. Inspecting the process: 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:

  1. A setInterval every 50 ms that prints request served at T ms (simulating users buying).
  2. 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.
  3. 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.
  • rss and heapUsed memory 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 cluster will 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
done

Typical 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 run node src/catalog.js > catalog.txt and the diagnostics will still show on screen without polluting the data file.
  • process.exitCode instead of process.exit(), consistent with the project convention.
  • The single-core warning is real: in a container with --cpus=1, starting 4 processes with cluster makes 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

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved