<< Home

PART 2: Node

Chapter 11: The Node Runtime

Node brings together the V8 JavaScript engine, the libuv library, and a small standard library to provide a fast, portable runtime for building servers, tools, and scripts. This chapter explains where Node came from, how its core pieces work, and which everyday facilities you will use to build and diagnose applications.

A brief history and where Node fits

Node began in 2009 when Ryan Dahl combined the V8 engine with a non-blocking I/O layer so JavaScript could handle many concurrent connections efficiently. The community quickly added npm for sharing packages. Today Node is a general purpose runtime that fits in several places: web servers and APIs, command line tools, build pipelines, desktop apps through wrappers, and edge or on-device utilities where a small single binary is helpful.

Node’s model is event driven and single threaded at the JavaScript level. Work that would block (file and network I/O, DNS, timers, compression, and similar) is delegated to a native layer so that your code stays responsive. This does not make Node automatically faster than other runtimes. It makes it a good fit for I/O-heavy tasks where many requests stay in flight while little CPU is used per request.

💡 Think of Node as a small operating environment for JavaScript. It provides a process, a module system, and a set of standard APIs for files, networking, and diagnostics.

Here is a tiny server to illustrate the programming model.

import http from "node:http";

const server = http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
  res.end("Hello from Node\n");
});

server.listen(8080, () => {
  console.log("Listening on http://localhost:8080");
});

Compilation, events and threading

V8 compiles JavaScript to machine code and runs it. libuv provides the cross-platform event loop and thread pool. The loop pulls I/O and timer events as they complete and schedules your callbacks. Microtasks (Promise reactions and queueMicrotask) run between macrotasks. Some scheduling primitives fire earlier than others, which matters when you coordinate work.

Task ordering in practice

The following program prints in a specific order because microtasks run between turns of the loop, and because process.nextTick is handled before Promise reactions.

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

A typical output is:

sync
nextTick
promise
timeout
immediate

The last two lines can swap depending on how the program reached the check phase. When a timer callback schedules setImmediate, the immediate often runs first. When the main script schedules both, the timer often fires first.

The event loop

Conceptually the loop progresses through phases that process different queues. The real implementation is optimized and platform specific, although this model is useful for reasoning.

PhaseWhat runs
TimerssetTimeout and setInterval callbacks whose thresholds have elapsed
Pending I/OSome system callbacks that were deferred from the previous tick
Idle/PrepareInternal housekeeping
PollI/O events are pulled; the loop can block here briefly when waiting
ChecksetImmediate callbacks
Close'close' events for sockets and handles

Between callbacks the microtask queue runs: first process.nextTick tasks then Promise reactions. Use microtasks to finish a small unit of work before yielding back to the loop. Avoid long microtask chains that starve I/O.

⚠️ Do not block the event loop with CPU-heavy work. Move heavy computation to a Worker thread or a native add-on. Otherwise the process cannot handle timers or I/O fairly.

Worker threads

Node has two sources of parallelism. First, libuv manages a fixed-size thread pool for operations such as file I/O and crypto. Second, you can use node:worker_threads to run JavaScript on additional threads when you need parallel CPU work. Workers communicate through messages and shared memory buffers.

import { Worker } from "node:worker_threads";

const w = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
w.on("message", (msg) => console.log("result:", msg));
w.postMessage({ task: "sha256", data: "..." });

ES modules support

Node supports both ES modules and CommonJS. The loader is selected per file and per package. File extensions decide the default (.mjs is ES modules, .cjs is CommonJS). A package can opt in globally using "type" in package.json. Exports are described with "exports" and optional "imports" maps for internal specifiers.

Choosing a module mode

When you set "type": "module" the default for .js becomes ES modules. You import with import and you export with export. When you set "type": "commonjs" the default for .js becomes CommonJS, and you use require and module.exports. You can always override per file with .mjs or .cjs.

{ 
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

With the export map above, consumers that use import will receive ./dist/index.js. Consumers that use require will receive ./dist/index.cjs. The same package supports both ecosystems cleanly.

Resolution and file layout

ES module resolution in Node is strict. File extensions are required. Bare specifiers resolve using "imports" and "exports" maps first, then node_modules lookup. CommonJS has more permissive resolution. Keep your published layout explicit to avoid surprises.

💡 Publish both ESM and CJS builds when you maintain a library. Applications can be pure ESM if all dependencies support it.

Useful package.json fields for Node

These are common fields that affect Node’s behavior at runtime or during install.

FieldPurpose
"type"Sets the default module format for .js files
"main"Entry point for CommonJS consumers
"exports"Public entry points for ESM and CJS; supports condition maps
"imports"Private alias map for your own package (for example #utils)
"bin"Command name to executable path mapping for CLI tools
"types"Path to .d.ts types for TypeScript users
"engines"Declares supported Node versions

Here is a cross-platform CLI layout that wires a command name to a module entry.

{ 
  "name": "greeter",
  "version": "1.0.0",
  "type": "module",
  "bin": { "greeter": "./bin/greeter.js" }
}
// bin/greeter.js
#!/usr/bin/env node
import { greet } from "../dist/index.js";

const name = process.argv[2] ?? "world";
console.log(greet(name));

Diagnostics, and built in tools

The node REPL is a fast way to test code and inspect values. It supports top-level await, input editing, and a small set of REPL commands. Diagnostics options help you profile, trace, and report problems in production.

Using the node REPL

Start the REPL by running node with no arguments. Special commands begin with a dot. The underscore holds the last result. The editor mode lets you paste multiline input comfortably.

$ node
> 2 + 2
4
> .editor
// paste a function…
function add(a, b) {
  return a + b;
}
^D
> add(2, 3)
5
💡 Use .save file.js to write the current REPL session to disk, and .load file.js to load one back in.

Checking and running code

Use node --check file.js to parse a script without running it. Use node --eval "…" to evaluate a short snippet. For built in tests you can use node --test to run test files that use the node:test module.

$ node --check server.js
$ node --eval "console.log(1 + 2)"
$ node --test

Inspecting and profiling

Use the Inspector protocol to debug. Run node --inspect app.js and connect from a Chromium-based browser’s DevTools, or start with --inspect-brk to pause before the first line. For CPU profiles and heap snapshots you can capture artifacts that DevTools understands.

$ node --inspect-brk app.js
Debugger listening on ws://…
⚠️ Avoid exposing an inspector port on a public interface. Bind to 127.0.0.1 or use an SSH tunnel so that only trusted users can connect.

Tracing and reports

Runtime traces can reveal costly operations. The --trace-… flags print events as they occur. Diagnostic reports capture a point-in-time snapshot of the process when you call the API or when specific conditions fire.

$ node --trace-gc app.js
$ node --trace-deprecation app.js
import process from "node:process";

process.report.writeReport(); // writes a JSON report to the current directory

Common command line patterns

These short commands help during development.

$ node --version
$ node --require dotenv/config app.js
$ NODE_OPTIONS="--max-old-space-size=2048" node app.js

When you understand how the loop schedules tasks, how modules load, and which diagnostics are available, you can reason about performance and correctness with confidence. The rest of this part uses these tools freely as we build servers, CLIs, and services.