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.
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.
| Phase | What runs |
| Timers | setTimeout and setInterval callbacks whose thresholds have elapsed |
| Pending I/O | Some system callbacks that were deferred from the previous tick |
| Idle/Prepare | Internal housekeeping |
| Poll | I/O events are pulled; the loop can block here briefly when waiting |
| Check | setImmediate 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.
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.
Useful package.json fields for Node
These are common fields that affect Node’s behavior at runtime or during install.
| Field | Purpose |
"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
.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://…
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.