# Node.js Architecture and Runtime

## 1. What "Runtime" Actually Means

Saying "Node is a JavaScript runtime" means more than "Node runs JS." A runtime is everything that has to exist **around** a language so that code written in it can actually do things the language spec itself doesn't define — reading a file, opening a socket, timing a callback. JavaScript the language has no concept of files, sockets, or timers; those are runtime-provided APIs. In a browser, the runtime is the browser engine (Web APIs). In Node, the runtime is Node itself: a C++ program that embeds a JS engine and glues it to the operating system.

```
"JavaScript" (the language spec)     →  ECMA-262: syntax, closures, prototypes, Promises...
"Node.js" (a runtime)                →  embeds the language + supplies fs, net, timers, process...
```

The Event Loop topic (`languages/javascript/27-event_loop`) already covers the generic mechanism — call stack, task queues, the loop draining them. This topic is about what Node's runtime is actually **made of** underneath that mechanism; the next topic (Event Loop / libuv) goes deep on the loop's phases themselves.

## 2. The Four Layers

```
┌─────────────────────────────────────────────┐
│  Your JavaScript code                          │
├─────────────────────────────────────────────┤
│  Node core JS modules (fs, http, net, ...)      │  ← JS, calls down into bindings
├─────────────────────────────────────────────┤
│  Node bindings (C++ glue, "node:internal")       │  ← translates JS calls into libuv/V8 calls
├───────────────────────┬───────────────────────┤
│  V8 (JS engine)          │  libuv (async I/O, loop)  │  ← the two C/C++ libraries Node embeds
└───────────────────────┴───────────────────────┘
```

- **V8** — Google's JavaScript engine. Parses and executes your JS, manages the heap and garbage collection, JIT-compiles hot code. V8 by itself has no idea what a file or a socket is — it only knows how to run JS and expose a C++ embedding API.
- **libuv** — a C library originally written for Node that provides a cross-platform abstraction over asynchronous I/O: the event loop itself, the thread pool, and OS-specific async I/O primitives (epoll on Linux, kqueue on macOS/BSD, IOCP on Windows).
- **Node bindings** — the C++ layer that exposes libuv's and OS's capabilities to JS as callable functions, and marshals data between V8's heap and native memory.
- **Core JS modules** — `fs`, `net`, `http`, `timers`, etc. are themselves mostly written in JavaScript, sitting on top of the bindings, providing the ergonomic API you actually call.

## 3. One JS Thread, One Loop, One Thread Pool

Node runs **exactly one thread** for your JavaScript — the same single-call-stack model the Event Loop topic describes. But "single-threaded" describes the JS execution model only; the *process* is not single-threaded at all:

```js
console.log(require("os").cpus().length); // your CPU core count — irrelevant to the JS thread count
```

Underneath, libuv maintains a **thread pool** (default size **4**, configurable via the `UV_THREADPOOL_SIZE` environment variable, capped at 1024) used for operations that have no efficient native async OS API. Those threads run in the background, doing blocking work on behalf of the JS thread, and post their results back to the event loop as a callback once done.

```js
// must be set BEFORE Node starts using the pool — read once at process init
process.env.UV_THREADPOOL_SIZE = 8; // has to be set before the first threadpool-using call, ideally via env var at launch
```

## 4. Threadpool vs. OS-Native Async — Two Different Async Mechanisms

This is the detail that trips people up: **not all of Node's async operations use the same underlying mechanism.**

| Category | Mechanism | Examples |
|---|---|---|
| Most `fs` operations | libuv **thread pool** (blocking syscalls run on a worker thread) | `fs.readFile`, `fs.writeFile`, `fs.stat` |
| DNS lookups (`dns.lookup`) | thread pool | `dns.lookup()` (but NOT `dns.resolve*`, which uses c-ares async) |
| Some `crypto` functions | thread pool | `crypto.pbkdf2`, `crypto.randomBytes` (async form) |
| Network sockets (`net`, `http`, `dgram`) | OS-native async I/O, **not** the thread pool | TCP/UDP sockets, HTTP requests |
| Timers | libuv's internal timer heap, no threads involved | `setTimeout`, `setInterval` |

The reason: modern operating systems provide genuinely async, non-blocking APIs for **network** I/O (epoll, kqueue, IOCP) — a socket read can be registered with the OS and the OS will notify libuv when data arrives, no dedicated thread required. Filesystem I/O has no equivalent non-blocking OS API on most platforms (a blocking `read()` syscall is often the only reliable option), so libuv fakes asynchrony for `fs` by running the blocking call on a thread pool worker and reporting back when it finishes.

![Node's four layers: JS code on core modules on C++ bindings, sitting on V8 and libuv, with libuv split between OS-native async I/O and its own thread pool](diagrams/01-node-architecture.svg)

## Under the Hood: Why This Split Exists and What It Costs You

### The threadpool is a small, shared, finite resource

Because the pool defaults to **4 threads total for the entire process**, every `fs` call, every async `crypto` call, and every `dns.lookup()` call are all competing for the same four workers. Issue five concurrent `fs.readFile()` calls and the fifth **queues** behind the first four — it doesn't start until a pool thread frees up, even though your JS thread itself is completely idle and could "look" concurrent from the outside.

```js
const fs = require("fs");
// with the default pool size of 4, the 5th of these queues behind the first 4
for (let i = 0; i < 5; i++) {
  fs.readFile(__filename, () => console.log(`read ${i} done`));
}
```

This is the practical reason `UV_THREADPOOL_SIZE` exists as a tuning knob for I/O-heavy servers doing lots of concurrent filesystem work — raising it gives more concurrent blocking-syscall slots. It does **nothing** for CPU-bound JavaScript, because pool threads run native (often C++) blocking work, not your JS — your JS still only ever runs on the one JS thread (see the Worker Threads topic for actual JS parallelism).

### Network I/O bypasses the pool entirely

`net`/`http`/`dgram` sockets are registered directly with the OS's async facility (epoll/kqueue/IOCP) via libuv's I/O-polling layer, which lives in the event loop's **poll phase** (detailed in the next topic). No thread pool worker is consumed per open socket — this is *why* Node can hold open tens of thousands of concurrent HTTP connections cheaply (the classic "C10K" story) while only 4 threads exist for blocking work: sockets aren't blocking work from libuv's perspective at all, they're OS-notified events.

### V8's Isolate is the real unit of "one JS engine instance"

The main thread runs inside one V8 **Isolate** — a self-contained instance of the engine with its own heap, garbage collector, and compiled-code cache. This is the same "Isolate" concept the JS Concurrency topic (`languages/javascript/34-concurrency`) describes for Web Workers — Node's `worker_threads` module (its own topic) spins up additional Isolates for real JS parallelism, each with a completely separate heap from the main thread's.

## Common Pitfalls

1. **Assuming Node is single-threaded in every sense** — only the *JS execution thread* is singular; the process also runs libuv's threadpool threads, V8's background GC/compiler threads, and any Worker Threads you spawn.
2. **Blocking the JS thread with a synchronous call** (`fs.readFileSync` in a request handler) and being surprised the whole server stalls — there's only one JS thread; a sync call on it blocks every other pending callback, network event, and timer, regardless of how many libuv threadpool slots are free.
3. **Believing raising `UV_THREADPOOL_SIZE` speeds up CPU-bound work** — it only adds capacity for threadpool-backed blocking I/O (`fs`, some `crypto`/`dns`), not for JS computation, which is confined to the single JS thread no matter the pool size.
4. **Setting `UV_THREADPOOL_SIZE` after Node has already started using the pool** — it must be set as an environment variable before the process reads it at startup; setting it mid-process from JS is unreliable.
5. **Not realizing network sockets don't consume threadpool slots** — conflating "async I/O" as one uniform mechanism obscures why Node scales differently for concurrent sockets vs. concurrent file reads.

## Quick Reference

```
Layers:        your JS → core JS modules → C++ bindings → V8 + libuv
JS threads:     exactly 1 (the main thread) — plus Worker Threads if you spawn them
libuv threadpool: default 4 threads, tune via UV_THREADPOOL_SIZE (set before startup)
  used by:      most fs.*, dns.lookup, some crypto.* (async forms)
  NOT used by:  net/http/dgram sockets (OS-native async: epoll/kqueue/IOCP), timers
CPU-bound JS work: blocks the single JS thread regardless of threadpool size — needs Worker Threads
```
