← back to the section

A Node service is written in one language, but two different programs run inside the process: the V8 engine executes your JavaScript, and the libuv library waits for the network, the disk and timers. While everything works, the border between them is invisible. It shows up when callbacks run in an order you did not expect, when "single-threaded" Node suddenly occupies four cores, or when the process grows by a gigabyte a day. Let us look under the hood so that such questions have an answer.

Two programs in one process

V8 is the JavaScript engine from Chrome: it parses code, compiles it to bytecode, runs it and recompiles hot functions into machine code along the way. V8 has no files, sockets or timers, only the language. Everything that waits for the outside world is done by libuv, a C library: it asks the operating system which sockets have data, keeps timers and runs a thread pool for operations the kernel cannot do without blocking. Node glues them together: functions such as fs.readFile and http.createServer are written in C++ on top of libuv and hand the result back to V8 as a callback.

This answers the question "can Node run without V8": not in a regular build, the engine is embedded, but the architecture allows it and experimental builds with another engine have existed. It also answers the question about threads: your JavaScript runs on one thread, while the Node process is multi-threaded, because libuv has its own pool and V8 has its own garbage collector and compiler threads.

Event loop phases

The event loop is not one queue but several that libuv visits in a fixed order. Four phases are worth knowing: timers, where setTimeout and setInterval callbacks whose time has come run; poll, where the loop waits for I/O events and runs their callbacks, which is where most server work happens; check, where setImmediate callbacks run; and close, where close handlers run. Between any two callbacks, not between phases but between callbacks, Node drains two queues that do not belong to libuv: first the process.nextTick queue, then the microtask queue with promise callbacks.

live example

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("sync");
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The program prints sync, nextTick, promise, then timeout and immediate. Synchronous code goes first because the loop has not started yet. nextTick comes before the promise because its queue is drained first. The order of the last two in the main module is not guaranteed: a zero-millisecond timer is really a one-millisecond timer, and if the loop reaches the timers phase before that millisecond has passed, immediate prints first. Inside an I/O callback the order is strict: setImmediate always precedes setTimeout, because the check phase follows poll directly.

Two consequences for a service. A recursive process.nextTick or an endless promise chain never lets the loop reach the poll phase, and the server stops responding while the CPU sits at one hundred percent. And any synchronous code that computes for more than tens of milliseconds blocks all connections at once: the loop has no preemption, it waits until the callback returns.

The thread pool and real multithreading

libuv serves the network without threads: sockets are non-blocking, and one system call reports thousands of ready connections. For files, DNS through getaddrinfo, zlib compression and crypto.pbkdf2 the operating system offers no such option, so libuv runs them in a thread pool of four threads by default. Hence a trap: a service that hashes a password on every request is limited not by the CPU but by four threads, and the fifth request waits. The pool size is set by the UV_THREADPOOL_SIZE environment variable, and it must be set before the process starts.

The pool does not help with computation in JavaScript itself; it runs only C code. Here there are three tools. worker_threads starts a separate V8 instance with its own event loop in the same process; data is copied between them through messages or shared through SharedArrayBuffer. cluster, or simply several processes behind a load balancer, runs a copy of the application per core, each with its own memory; that is how an HTTP server is scaled. child_process starts any external program. The rule is simple: heavy computation inside a service means a worker, a growing number of concurrent requests means processes, and neither is needed until a profile shows the event loop is busy computing.

Garbage collection in V8

V8 frees memory with a generational collector. New objects land in the young generation, a small area the collector visits often and quickly by copying live objects from one half to the other. An object that survives two such collections moves to the old generation, where collection is rarer and more expensive: marking reachable objects from the roots, sweeping and occasionally compacting. V8 does most of the marking in parallel and incrementally so that pauses stay within a few milliseconds, but an old generation of several gigabytes still means noticeable pauses.

The heap size is limited: by default around two to four gigabytes depending on the version and the machine, and when it is exhausted the process dies with FATAL ERROR: Reached heap limit. The --max-old-space-size flag in megabytes raises the limit, and in a container it is set below the memory limit so that Node dies with a clear error instead of OOMKilled. You can see what happens without tools:

live example

const before = process.memoryUsage();
const leak = [];
for (let i = 0; i < 200000; i++) leak.push({ id: i, payload: "x".repeat(64) });
const after = process.memoryUsage();
console.log("heapUsed grew, MB:", ((after.heapUsed - before.heapUsed) / 1e6).toFixed(1));
console.log("rss, MB:", (after.rss / 1e6).toFixed(1));
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

heapUsed is the live objects in the V8 heap, rss is the whole process memory including buffers and code. If heapUsed grows from request to request and does not drop after a collection, the objects are reachable, and that is a leak.

Leaks: where they come from and how to find them

The collector removes only what is unreachable, so a leak in Node is always a reference someone forgot. Four typical places. A global cache or Map without eviction that receives an entry per request. EventEmitter listeners added in a handler and never removed: Node warns about them with MaxListenersExceededWarning. Closures that keep a large object for the sake of one field, such as a timer callback that captured the whole request. And resources never closed: timers without clearTimeout, connections without end.

Leaks are found with heap snapshots. Start the process with --inspect, attach DevTools or use node --heapsnapshot-signal=SIGUSR2, take two snapshots some time apart under load and compare them: the object class whose count only grows is the culprit, and the "Retainers" view shows who holds it. For a first suspicion a chart of process.memoryUsage().heapUsed in metrics is enough. A leak is convenient to reproduce in a test: a hundred thousand requests to one handler and a check that the heap returns to its initial size after a collection.

Errors nobody caught

An exception in a handler's synchronous code is caught by the framework, but an error thrown from a timer or event callback goes to the whole process. An unhandled exception fires the uncaughtException event and by default terminates the process; a rejected promise without a catch fires unhandledRejection, and since Node 15 that also crashes the process. This is the right behavior: the state after such an error is unknown, and continuing is more dangerous than restarting.

So handlers for these events are installed for one purpose: write the error to the log, send a metric and exit with a non-zero code so that the orchestrator starts a fresh process. A handler that "swallows and continues" gives you a service that runs, but nobody knows how. Graceful shutdown on SIGTERM, where the server stops accepting connections and waits for the current ones, is covered in the article on NestJS configuration and lifecycle.

In short

  • A Node process is V8 for JavaScript plus libuv for waiting on the network, disk and timers; JavaScript is single-threaded, the process is not.
  • Loop phases: timers, I/O poll, check with setImmediate, close; between callbacks the process.nextTick queue is drained, then promise microtasks.
  • The order of setTimeout(0) and setImmediate in the main module is not guaranteed; inside an I/O callback setImmediate goes first; a recursive nextTick starves the loop.
  • Files, DNS, zlib and crypto go through the libuv pool of four threads, UV_THREADPOOL_SIZE widens it; JavaScript computation is sped up with worker_threads and scaled with processes.
  • The V8 collector is generational: the young generation by fast copying, the old one by marking with incremental pauses; --max-old-space-size sets the heap limit.
  • A leak is a forgotten reference: a cache without eviction, listeners never removed, closures capturing too much, timers never cleared; find it with two heap snapshots through --inspect.
  • uncaughtException and unhandledRejection are signals to exit with a log entry, not to carry on.

Further reading