Node.js Fundamentals: How the Runtime Actually Works

I break down how Node.js works under the hood — V8's JIT compilation, libuv's async I/O, and when to choose CommonJS over ESM.

Node.js Fundamentals: How the Runtime Actually Works

Most developers who learn that Node.js is single-threaded assume it handles requests one at a time — that it’s fundamentally limited compared to multi-threaded runtimes. That’s a reasonable conclusion from the premise. It’s also exactly backwards.

The single-threaded design is precisely what makes Node.js efficient for I/O work. A thread-per-request server uses memory and scheduler time on connections that spend most of their lives waiting. Node.js avoids that overhead by design.

Understanding why requires looking at two components that sit beneath your JavaScript: V8 and libuv. By the end of this post, you’ll have a clear mental model of how code actually executes, why async I/O doesn’t block the main thread, and what the practical difference is between CommonJS and ESM — the module format choice you make at the start of every project.

Series: Part 1 of 6 in the Node.js fundamentals series.

On this page

The two pieces Node.js is built from

Node.js is not a language. It’s a runtime environment: it takes your JavaScript and provides the infrastructure to execute it outside the browser.

Two components do the actual work:

  • V8 — the JavaScript engine originally built for Chrome. It parses and compiles your JavaScript to native machine code.
  • libuv — a cross-platform C library focused on async I/O. It provides the event loop, a thread pool, and async networking primitives.

Your JavaScript never touches the file system, network, or timers directly. It calls Node.js APIs that delegate to one of these two components. Knowing which is which makes the rest of the runtime’s behavior much easier to predict.

V8: JIT compilation turns scripts into machine code

V8 is what runs your JavaScript. It was originally built for Chrome, then chosen as the engine powering Node.js when the project launched in 2009.

What surprises many developers: V8 doesn’t interpret JavaScript. It compiles it. Specifically, V8 uses just-in-time (JIT) compilation — it converts JavaScript to native machine code while the program runs. This sits between pure interpretation (execute each line as read) and ahead-of-time compilation (compile everything before running).

The JIT approach starts fast and gets faster. When a function first runs, V8 compiles it quickly with minimal optimization. If it notices that function being called repeatedly with similar argument shapes, it recompiles with aggressive optimizations. If those assumptions later break — a function that was always passed numbers suddenly receives a string — V8 deoptimizes and falls back. Long-running Node.js servers tend to improve in throughput over the first few minutes as V8 settles into hot paths.

This also means cold-start benchmarks often underrepresent the performance of production servers. The JIT optimizer hasn’t had time to warm up.

You don’t control any of this directly, but it shapes one common misread: writing JavaScript that looks “fast” (no async, all synchronous, simple types) while actually harming V8’s optimization opportunities by mixing types inconsistently.

libuv: where non-blocking I/O actually happens

When you call fs.readFile(), your JavaScript thread doesn’t wait. The call returns immediately, and a callback fires when the file is ready. The mechanism behind that behavior is libuv, a C library that sits between Node.js and the operating system.

When Node.js needs to do I/O, it delegates to libuv. For network I/O, libuv uses OS-level event notification mechanisms — epoll on Linux, kqueue on macOS, IOCP on Windows — to wait for results without consuming CPU cycles. For file system access, libuv uses a thread pool (four threads by default) because most OS file APIs lack native async support.

Either way, the outcome is the same from your code’s perspective: the callback runs on the main JavaScript thread once the work is done, and that thread was free for other work in the meantime.

Try this

  1. Create read-test.js with this content:

    const fs = require('node:fs');
    fs.readFile('./read-test.js', () => { console.log('file done'); });
    console.log('this runs first');
  2. Run node read-test.js

Expected result: You’ll see this runs first before file done — even though the file was almost certainly ready in microseconds. The callback is queued and runs after the current synchronous code finishes executing.

This ordering isn’t an accident or a race condition. It’s the event loop at work. After the current synchronous execution frame finishes, the event loop picks up the next queued callback. The next post in this series goes into the precise phases of that loop — microtask queue, timers, I/O callbacks — but the key point here is that libuv, not V8, manages when callbacks become available.

Why single-threaded doesn’t mean one connection at a time

The mental model shift: “single-threaded” means JavaScript executes on one thread. It doesn’t mean one I/O operation at a time.

When your server handles a request that reads from a database, the JavaScript thread starts the query and immediately becomes free to handle the next incoming request. Both requests have outstanding I/O operations simultaneously — they’re both waiting inside the operating system, with libuv tracking them. Each callback runs when its I/O finishes. The JavaScript thread never blocked.

The official Node.js documentation illustrates this clearly: consider a request that takes 50ms total, where 45ms is database I/O. Using non-blocking async operations frees up 45ms per request for handling other work. Multiply that across hundreds of concurrent requests and you see why Node.js can handle high-concurrency API workloads without spawning a thread per connection.

Compare that to thread-per-request models: each connection gets a thread, which means memory overhead per connection, OS scheduler overhead, and potential lock contention across threads. Node.js avoids all of that by keeping a single JS thread and letting the OS manage concurrent I/O through libuv.

Check this before moving on

  • You can explain why fs.readFile() doesn’t block the event loop
  • You know that libuv — not V8 — handles async I/O
  • You understand that “single-threaded” describes JavaScript execution, not I/O operations

CommonJS and ESM: two module formats, one choice to make

Node.js ships with two module systems. You’ll use one of them on every project.

CommonJS (CJS) is the original, introduced with Node.js itself. It uses require() and module.exports:

// math.cjs
module.exports.add = (a, b) => a + b;

// app.cjs
const { add } = require('./math.cjs');

Before your module code runs, Node.js wraps it in a function:

(function(exports, require, module, __filename, __dirname) {
  // your module code lives here
});

That wrapper is where __filename, __dirname, and module.exports come from — they’re injected parameters, not actual globals. It also creates a closure, scoping all top-level variables to the module rather than leaking them globally. If you’ve read the post on closures in JavaScript, this is that pattern in practice. Modules are cached after first load, so multiple require() calls to the same path return the same object.

ESM is the current standard, using import and export. Node.js has fully supported it since v15.3.0:

// math.mjs
export const add = (a, b) => a + b;

// app.mjs
import { add } from './math.mjs';

The differences that matter in practice:

CommonJS ESM
require() Available Not available (use import)
__filename / __dirname Available Use import.meta.filename / import.meta.dirname
Top-level await Not supported Supported
Static import analysis Not possible Enables tree shaking
Default file extension .js, .cjs .mjs, or .js with "type": "module" in package.json

Node.js decides which system applies based on file extension (.cjs forces CommonJS, .mjs forces ESM) or the "type" field in package.json. Adding "type": "module" to package.json makes .js files default to ESM across the project.

For new projects today, ESM is the standard choice. If you’re working with TypeScript, the module format interacts with tsconfig.json settings — another layer to keep in mind when configuring a new project.

Mixing CommonJS and ESM in the same codebase is possible, but the interop has edge cases. ESM can import CommonJS modules as default exports. CommonJS can load synchronous ESM modules via require() as of Node.js v22, but ESM with top-level await still requires dynamic import(). The friction is low for greenfield projects but adds up in larger mixed codebases.

What happens when you give Node.js CPU-heavy work

The async I/O model has one firm limit: the JavaScript thread.

Synchronous operations that consume CPU — parsing a large JSON payload with JSON.parse(), running a heavy sort on a large array, generating a report — run on the main JavaScript thread and block the event loop. While that computation runs, no other callbacks can execute. Every pending request waits.

This is the architecture, not a bug. The event loop works because I/O operations hand off to libuv and give the thread back. CPU work can’t do that — it just occupies the thread until it finishes.

The approaches that help:

  • Worker Threads (node:worker_threads) — run JavaScript on a separate thread with message-passing communication. Designed for CPU-heavy computations.
  • Child processes (node:child_process) — spawn a completely separate process. Useful for running external tools or workloads that should be fully isolated.
  • Offload to the database — push filtering, aggregation, and transformation into queries rather than loading raw data and processing it in JavaScript.

The pattern to watch for: reading a large dataset into memory and then transforming it synchronously on the main thread. One heavy request will stall the entire server for its duration.

Use the runtime model to reason about your code

The mental model that comes out of all this: Node.js is fast for I/O workloads because V8 handles JavaScript efficiently, and libuv keeps the JavaScript thread free while I/O waits. The performance bottleneck in a Node.js server is almost never the JavaScript execution itself — it’s usually either blocking I/O (using synchronous APIs where async ones exist) or CPU-heavy synchronous work on the main thread.

That boundary — where the JavaScript thread ends and where libuv takes over — is the diagnostic boundary too. When a Node.js server slows under load, you’re usually looking at one of two things: a synchronous blocking call holding the event loop, or CPU work that should be offloaded to worker threads.

The execution context model and the promise and async/await mechanics covered in related posts on this site both operate within this same architecture. V8 provides the call stack and execution contexts. libuv manages the event loop and callback scheduling. Together, they’re the foundation everything else builds on.

Sources