11. Node.js Server-Side JavaScript Runtime
Node.js takes the JavaScript engine out of the browser and puts it on servers, laptops and build machines, with direct access to files, network sockets, processes and the operating system. It is the runtime behind most web tooling (bundlers, linters, test runners, the TypeScript compiler) and a large share of web back ends, so even front-end developers use it every day. This chapter covers what Node.js is and how it is built (11.1), its package manager npm (11.2), the non-blocking I/O model and event loop that make it scale (11.3), events and streams (11.4), the operating-system APIs (11.5) and the built-in HTTP layer on which frameworks such as Express (Chapter 12) are built. It assumes the JavaScript concurrency model from Chapter 5 and the HTTP protocol from Chapter 10.
<a id="111-history-and-system-overview"></a>
11.1 History and System Overview
This subchapter explains why Node.js was created, the components it is built from, how its releases are organised, how code is executed and loaded as modules, and how it compares with the browser and with newer runtimes such as Deno and Bun.
Origins and Motivation of Node.js
In 2009 most web servers handled concurrency with one thread (or process) per connection. Apache's prefork model, Java servlet containers and Ruby/Python application servers all followed this idea: when a request arrives, a thread is assigned to it, and that thread waits while the database answers, while a file is read or while a slow client uploads data. Waiting threads are not free: each needs its own stack (often 1-8 MB of reserved memory) and the operating system must schedule them. A server with 10,000 mostly idle connections (long-polling chat clients, for example) needed 10,000 threads. This was known as the C10K problem.
Ryan Dahl presented Node.js at JSConf EU in November 2009 with a different idea: never let the program wait. Instead of a thread that blocks on I/O, a single thread starts the I/O operation, registers a callback and immediately moves on to other work. The operating system notifies the program when data is ready. Servers like nginx already used this event-driven design internally, but writing applications this way in C was hard. Dahl's insight was that JavaScript was a perfect fit:
- It already had first-class functions and closures, which make callbacks natural.
- It had no existing standard library of blocking I/O functions. In Python or Ruby, every library already used blocking
read()calls; in JavaScript, there was nothing to unlearn. - Browser developers were already used to the event-driven model (click handlers,
XMLHttpRequestcallbacks). - Google's V8 engine (2008) had just made JavaScript fast.
A restaurant analogy captures the difference. The thread-per-request model gives every table its own waiter who stands next to the kitchen door until that table's food is ready. The Node.js model has one waiter who takes an order, hands it to the kitchen, and serves other tables until the kitchen rings a bell.
Thread per request (classic model) Event-driven (Node.js model)
req A --> [Thread 1] --wait DB--------> req A --\
req B --> [Thread 2] --wait file------> req B ---+--> [single JS thread] --start I/O--> OS / thread pool
req C --> [Thread 3] --wait network---> req C --/ ^ |
| callback when I/O is done |
3 threads, mostly idle, each with a stack +------------------------------+
1 thread, never idle while there is work to do
Milestones.
| Year | Event |
|---|---|
| 2009 | First release; npm created by Isaac Schlueter shortly after (2010) |
| 2011 | Windows support via libuv, which replaced libev |
| 2014 | Governance dispute leads to the io.js fork |
| 2015 | io.js and Node.js merge under the Node.js Foundation; Node 4 is the first unified release; LTS model introduced |
| 2019 | Node.js Foundation and JS Foundation merge into the OpenJS Foundation |
| 2020-2026 | ES modules, built-in fetch, test runner, watch mode, permission model, native TypeScript type stripping |
Why it took off. Beyond performance, Node.js gave web developers one language on both sides of the network. Validation code, data models and utilities could be shared between client and server, and the whole front-end tooling ecosystem (Grunt, Gulp, webpack, Babel, Vite, ESLint, TypeScript) was built on Node.js because front-end developers could write tools in the language they already knew.
What Node.js is not good at. The single JavaScript thread that makes I/O cheap makes CPU-heavy work expensive: a long computation blocks every other request. Image processing, video encoding and heavy numerical work belong in worker threads (11.5), native add-ons or other services.
Key Takeaways
- Node.js was created to handle many concurrent connections efficiently, by replacing blocking threads with non-blocking I/O and callbacks.
- JavaScript fit the model because of closures, an existing event-driven culture, the absence of blocking I/O libraries and the fast V8 engine.
- One language across client, server and tooling explains much of its adoption.
- It excels at I/O-bound workloads (APIs, proxies, real-time apps) and struggles with CPU-bound work on the main thread.
Practice
- (Beginner) List three kinds of applications well suited to Node.js and two that are poorly suited, and justify each in terms of I/O-bound versus CPU-bound work.
- (Intermediate) Estimate the memory needed to keep 20,000 idle connections open with one thread per connection (assume a 1 MB stack per thread), and compare with an event-driven server that stores a few kilobytes of state per connection.
- (Interview) "Node.js is single-threaded. How can it handle thousands of concurrent requests?" Hint: separate the thread that runs JavaScript from the work done by the operating system kernel and the libuv thread pool while JavaScript is not running.
The V8 Engine and libuv
Node.js is not a new language or a new engine. It is glue around two major components, each solving a different problem: V8 runs JavaScript, and libuv talks to the operating system without blocking.
V8: executing JavaScript. V8 is Google's open-source JavaScript and WebAssembly engine, written in C++ and also used in Chrome and Edge. It parses JavaScript, compiles it and manages memory:
source code
|
v
Parser --> AST --> Ignition (bytecode interpreter) -- runs immediately, collects type feedback
|
| hot functions (called often, stable types)
v
Sparkplug / Maglev (fast baseline/mid-tier compilers)
|
v
TurboFan (optimising compiler) --> fast machine code
|
| assumption broken (e.g. a new type shows up)
v
deoptimisation back to bytecode
V8 also provides the garbage collector (Orinoco), a generational collector: short-lived objects are allocated in a small "young generation" that is collected quickly and often, and survivors are promoted to the "old generation". In Node.js you can tune the old-generation size with --max-old-space-size (in MB), which matters for memory-hungry services or containers with strict limits.
What V8 does not provide is anything outside the ECMAScript specification: no setTimeout, no console, no file access, no networking. Those are supplied by the host (the browser or Node.js).
libuv: talking to the operating system. Every operating system has its own mechanism for efficient non-blocking I/O: epoll on Linux, kqueue on macOS and BSD, IOCP (I/O Completion Ports) on Windows. libuv is a C library that hides these differences behind one API and adds:
- the event loop (11.3);
- asynchronous TCP, UDP, pipes and TTYs;
- a thread pool for operations the OS cannot do asynchronously (most file-system calls, DNS
getaddrinfo, some crypto and compression); - timers, signals, child processes and file watching.
+--------------------------------------------------------------+
| Your JavaScript (app + npm packages) |
+--------------------------------------------------------------+
| Node.js core library in JavaScript (lib/*.js) |
| fs, http, net, stream, events, crypto, ... |
+--------------------------------------------------------------+
| Node.js C++ bindings (src/*.cc) |
+-----------------------------+--------------------------------+
| V8 | libuv |
| JS execution, JIT, GC | event loop, async I/O, |
| | thread pool, timers |
+-----------------------------+--------------------------------+
| Other C/C++ deps: OpenSSL (TLS, crypto), llhttp (HTTP |
| parser), nghttp2 (HTTP/2), zlib, brotli, c-ares, ICU, ... |
+--------------------------------------------------------------+
| Operating system (Linux, macOS, Windows) |
+--------------------------------------------------------------+
Seeing the components from JavaScript.
// versions.js — print the versions of the main components
console.log(process.versions);
// {
// node: '24.x.x',
// v8: '13.x....', // the JS engine
// uv: '1.5x.x', // libuv
// openssl: '3.x.x', // TLS and crypto
// llhttp: '9.x.x', // HTTP/1.1 parser
// ...
// }
# V8 has hundreds of flags; Node passes them through
node --v8-options | grep -i "max-old-space"
node --max-old-space-size=4096 server.js # allow up to ~4 GB of old-generation heap
Key Takeaways
- V8 executes JavaScript (parsing, JIT compilation, garbage collection) and knows nothing about files or networks.
- libuv provides the event loop, cross-platform non-blocking I/O (epoll, kqueue, IOCP) and a thread pool for operations that cannot be done asynchronously by the OS.
- Node.js combines them with bindings and a JavaScript standard library, plus libraries like OpenSSL and llhttp.
process.versionsshows exactly which component versions a given Node build uses.
Practice
- (Beginner) Run
node -p process.versionsand identify which entries correspond to the JavaScript engine, the event loop library, the TLS library and the HTTP parser.- (Intermediate) Write a script that allocates arrays in a loop until it crashes, then run it with
--max-old-space-size=64and--max-old-space-size=256and compare how far it gets.- (Interview) "Why does Node.js need a thread pool if it uses non-blocking I/O?" Hint: consider which kinds of operations
epoll/kqueuecan monitor, and what regular file reads andgetaddrinfolook like at the OS level.
Runtime Architecture and Bindings
Between your fs.readFile() call and the kernel's read() system call there are several layers. Knowing them explains why some APIs are fast, why some are asynchronous and how native add-ons fit in.
From a JavaScript call to the OS and back. Follow fs.readFile('data.txt', cb):
1. your code fs.readFile('data.txt', cb)
2. lib/fs.js validates arguments, creates a request object (FSReqCallback)
3. C++ binding src/node_file.cc: unwraps the arguments, calls uv_fs_open/uv_fs_read
4. libuv queues the work on the thread pool; JS thread returns immediately
5. thread pool a worker thread performs the blocking read() system call
6. libuv puts the completion in the event loop's queue
7. event loop in the poll phase, calls back into C++
8. C++ binding converts the result (bytes -> Buffer) and calls the JS callback
9. your code cb(null, buffer) runs on the main thread
The key point: steps 1-4 take microseconds, then your JavaScript continues. Steps 5-6 happen on another thread, and step 9 is scheduled later by the event loop.
Bindings. The binding layer is C++ code that exposes native functionality to JavaScript using V8's embedding API: it creates JavaScript functions backed by C++ functions, converts between V8 values and C/C++ types, and keeps objects alive across asynchronous calls. Node's own core modules are split in two:
| Layer | Location in the Node.js source | Role |
|---|---|---|
| Public JavaScript API | lib/*.js (e.g. lib/fs.js) | Argument validation, convenience, streams, promises |
| Internal bindings | src/*.cc (e.g. src/node_file.cc) | Thin wrappers around libuv, OpenSSL, zlib and so on |
Native add-ons. Third-party code can also add native modules, compiled to .node shared libraries and loaded with require(). The recommended interface is Node-API (N-API), a stable C ABI: an add-on built against Node-API works across Node.js major versions without recompiling, unlike code written directly against V8's C++ API, which changes often.
// Using a popular native add-on: the npm package ships prebuilt .node binaries
const sharp = require('sharp'); // libvips image processing in C, not JavaScript
sharp('photo.jpg')
.resize(800)
.toFile('photo-800.jpg') // the heavy work runs off the JS thread
.then(info => console.log('resized', info.width, 'x', info.height));
Native add-ons are how CPU-intensive libraries (image processing, database drivers, bcrypt) achieve native speed. The downside is installation complexity: if no prebuilt binary matches your platform, npm must compile C/C++ code with node-gyp, which requires Python and a compiler toolchain. WebAssembly is an increasingly common alternative because one .wasm file runs everywhere.
Bootstrapping. When you run node app.js, Node initialises V8 and libuv, runs its own internal JavaScript bootstrap code (from a snapshot, for fast start-up), sets up globals such as process, loads your entry module, runs it to completion, and then enters the event loop. The process exits when the loop has nothing left to wait for: no pending timers, sockets, servers or file operations.
// exits immediately: nothing keeps the event loop alive
console.log('done');
// stays alive for 1 second: a pending timer keeps the loop alive
setTimeout(() => console.log('timer'), 1000);
// stays alive forever: a listening server is an active handle
require('node:http').createServer((req, res) => res.end('ok')).listen(3000);
// .unref() tells the loop not to wait for this handle
setInterval(() => console.log('heartbeat'), 1000).unref(); // does not keep the process alive on its own
Key Takeaways
- A core API call travels from
lib/*.jsthrough C++ bindings to libuv and the OS, and its result returns via the event loop. - Bindings convert between V8 values and native types; Node-API provides a stable ABI for third-party native add-ons.
- Native add-ons give native speed but complicate installation; prebuilt binaries and WebAssembly reduce the pain.
- A Node process lives as long as the event loop has active handles or requests;
unref()opts a handle out.
Practice
- (Beginner) Write a script with a
setTimeoutof 2 seconds and then call.unref()on it. Explain why the process exits before the callback runs.- (Intermediate) Open the Node.js repository on GitHub and locate
lib/fs.jsandsrc/node_file.cc. Find wherereadFilehands work to the C++ layer, and describe what each side is responsible for.- (Interview) "What is Node-API and why would a native add-on author choose it over the V8 C++ API?" Hint: think about ABI stability across Node major versions and what happens to users when they upgrade Node.
Release Lines, LTS and Version Management
A server runtime must be both modern and stable. Node.js resolves this tension with a predictable release schedule that separates experimentation from long-term support, so teams know which version to run in production and how long it will receive security fixes.
The release schedule. A new major version is released every six months:
- April: an even-numbered major (for example 22, 24, 26).
- October: an odd-numbered major (23, 25).
Each release goes through phases:
| Phase | Duration | What it gets | Who should use it |
|---|---|---|---|
| Current | ~6 months after release | New features, possibly breaking changes in the next major | Library authors testing compatibility, early adopters |
| Active LTS | ~12 months (even majors only) | Bug fixes, security fixes, carefully backported features | Production |
| Maintenance LTS | ~18 months | Critical bug and security fixes only | Production systems that have not yet migrated |
| End of life | - | Nothing, including security patches | Nobody |
Odd-numbered majors never become LTS: they reach end of life after about eight months. Even-numbered majors are promoted to LTS in October of their release year, for a total of about 30 months of support. Each LTS line also gets a codename (Node 20 "Iron", 22 "Jod", 24 "Krypton"). The project has discussed moving to fewer majors per year, so always check the official schedule on nodejs.org and in the nodejs/Release GitHub repository rather than relying on memorised dates.
2024 2025 2026 2027 2028
Node 22 [Cur][==== Active LTS ====][====== Maintenance ======]
Node 23 [Cur]X
Node 24 [Cur][==== Active LTS ====][==== Maintenance ====]
Node 25 [Cur]X
Node 26 [Cur][==== Active LTS ====][== ...
Rule of thumb. Run an Active LTS in production, test against the Current line in CI if you publish libraries, and plan an upgrade before your line reaches end of life.
Semantic versioning of Node itself. Within a major line, minor versions add backwards-compatible features and patches fix bugs. Breaking changes (removed APIs, changed defaults) only happen in majors and are preceded by deprecations, which go through stages: documentation-only, runtime warning, then removal.
Managing versions locally. Different projects need different Node versions, so developers use a version manager instead of the system package:
| Tool | Platforms | Notes |
|---|---|---|
| nvm | macOS, Linux (nvm-windows is a separate project) | Shell script, the classic choice |
| fnm | All | Fast (Rust), reads .nvmrc and .node-version |
| Volta | All | Pins versions per project in package.json |
| mise / asdf | All | Polyglot managers (Node, Python, Ruby, ...) |
| Corepack | Bundled with some Node versions | Pins the package manager (yarn, pnpm) version, not Node itself |
nvm install 24 # install the latest Node 24.x
nvm use 24 # switch the current shell to it
echo "24" > .nvmrc # pin the project's version; `nvm use` with no argument reads it
node --version # v24.x.y
Projects should also declare supported versions in package.json, which npm checks (and warns about) during installation:
{
"engines": {
"node": ">=22.12"
}
}
In CI and containers, pin the version explicitly, for example with the node:24-slim Docker image or actions/setup-node with node-version-file: .nvmrc.
Key Takeaways
- A new major is released every April (even) and October (odd); only even majors become LTS.
- Production should run an Active or Maintenance LTS version; odd releases are short-lived.
- Breaking changes happen only in major versions and are announced by deprecations first.
- Use a version manager locally, pin the version with
.nvmrcorengines, and use the same version in CI and production.
Practice
- (Beginner) Check the official Node.js release schedule and write down which versions are currently Active LTS, Maintenance LTS and Current.
- (Intermediate) Install two Node major versions with a version manager, create two directories each with its own
.nvmrc, and verify thatnode --versionchanges when you switch directories.- (Interview) "Your company runs Node 20 in production. What is your upgrade plan and why?" Hint: consider end-of-life dates, which version you would target, how you would find deprecated API usage, and how you would test before switching.
The REPL and Script Execution
Node.js can be used in two ways: interactively, to explore APIs and test small expressions, and as a script runner, where it executes files. Both are started by the same node command.
The REPL. Running node with no arguments starts the Read-Eval-Print Loop: it reads an expression, evaluates it, prints the result and waits for the next one. It is the quickest way to check how an API behaves.
$ node
Welcome to Node.js v24.x.
Type ".help" for more information.
> 2 ** 10
1024
> const os = require('node:os') // core modules can be required
undefined
> os.cpus().length
8
> _ // `_` holds the last result
8
> await fetch('https://example.com').then(r => r.status) // top-level await works in the REPL
200
> .editor // multi-line editing mode, Ctrl+D to run
> .exit // or press Ctrl+D (Ctrl+C twice also exits)
Useful REPL features include Tab completion of objects and modules, .load file.js to evaluate a file into the session, .save file.js to save the session, and _error for the last thrown error.
Running scripts. node file.js runs a file as a module and then enters the event loop. Common command-line options:
| Command | Purpose |
|---|---|
node app.js arg1 arg2 | Run a file; arguments are in process.argv (11.5) |
node -e "console.log(1 + 1)" | Evaluate a string |
node -p "process.platform" | Evaluate and print the result |
node --watch app.js | Restart automatically when the file or its imports change |
node --env-file=.env app.js | Load environment variables from a file (Node 20.6+) |
node --inspect app.js | Enable the debugger (11.6) |
node --test | Run the built-in test runner on test files |
node --run build | Run a package.json script without npm overhead (Node 22+) |
node app.ts | Run TypeScript by stripping types (Node 22.18+/23.6+ by default) |
Shebang scripts. On Unix-like systems, a file can be made directly executable:
#!/usr/bin/env node
// hello — run with ./hello after `chmod +x hello`
// The first line tells the OS to run this file with `node` found on the PATH.
console.log(`Hello, ${process.argv[2] ?? 'world'}!`);
chmod +x hello
./hello Ada # Hello, Ada!
Running TypeScript directly. Recent Node versions can run .ts files by stripping type annotations (replacing them with whitespace) without type-checking them. Only "erasable" syntax is supported: type annotations, interfaces and type aliases work, while features that generate code, such as enum and parameter properties, require the --experimental-transform-types flag or a separate compiler. Type-checking is still done by tsc --noEmit (Chapter 9) in your editor and CI.
// greet.ts — run with: node greet.ts
interface User { name: string; } // erased at run time
const greet = (u: User): string => `Hi, ${u.name}`;
console.log(greet({ name: 'Grace' }));
Key Takeaways
nodealone starts the REPL;node file.jsruns a script and then the event loop until it has nothing left to do.- The REPL supports top-level
await,_for the last result and dot-commands such as.editorand.load. - Modern Node has built-in watch mode,
.envloading, a test runner,--runfor scripts and type stripping for TypeScript. - A shebang line (
#!/usr/bin/env node) turns a script into an executable command on Unix-like systems.
Practice
- (Beginner) In the REPL, use Tab completion to list the functions of the
pathmodule, then usepath.parseon/home/user/report.final.pdfand inspect the result.- (Intermediate) Write an executable script
countlinesthat prints the number of lines in the file given as its first argument, and run it withnode --watchwhile editing it.- (Interview) "Node can now run
.tsfiles. Does that mean we no longer needtsc?" Hint: distinguish removing type annotations from checking them, and consider which TypeScript features generate JavaScript code.
Globals: process, dirname and Buffer
In the browser, the global object is window and it is full of DOM APIs. Node.js has a different set of globals oriented towards the process and the operating system. Some are true globals, and some only look global because the module system injects them.
The global object. globalThis is the standard name for the global object in every JavaScript environment. In Node.js it is also available as global. Variables declared with var at the top level of a module do not become properties of it, because each file has its own module scope.
Globals shared with browsers. Node implements many web-standard APIs, so code can often be shared:
| Category | Globals |
|---|---|
| Timers | setTimeout, setInterval, setImmediate (Node-only), queueMicrotask |
| Console | console |
| Networking | fetch, Request, Response, Headers, FormData, WebSocket (client, Node 22+) |
| URLs and encoding | URL, URLSearchParams, TextEncoder, TextDecoder, atob, btoa |
| Cancellation and events | AbortController, AbortSignal, EventTarget, Event |
| Data | structuredClone, Blob, ReadableStream and other Web Streams |
| Crypto | crypto (Web Crypto: crypto.randomUUID(), crypto.subtle) |
Node-specific globals.
process: information about and control over the running process: arguments, environment, exit code, memory, events (11.5).Buffer: a class for binary data (11.4).require,module,exports,__filename,__dirname: provided only in CommonJS modules (next topic).
console.log(process.pid); // process ID
console.log(process.platform); // 'linux', 'darwin', 'win32'
console.log(process.cwd()); // current working directory (where the command was run)
console.log(process.memoryUsage().heapUsed / 1e6, 'MB');
const buf = Buffer.from('héllo', 'utf8');
console.log(buf); // <Buffer 68 c3 a9 6c 6c 6f> ('é' takes two bytes)
console.log(buf.length); // 6 bytes, though the string has 5 characters
__dirname vs process.cwd(). This is a common source of bugs. process.cwd() is the directory the user ran the command from; __dirname is the directory the source file lives in. Code that loads files next to itself must use the file's location:
/home/ada/project/
├── src/
│ ├── app.js <- contains readFileSync('./config.json')
│ └── config.json
// src/app.js
const fs = require('node:fs');
const path = require('node:path');
// Fragile: resolved against process.cwd()
// works with `cd src && node app.js`, fails with `node src/app.js` run from /home/ada/project
fs.readFileSync('./config.json');
// Robust: resolved against the file's own directory, whatever the cwd is
fs.readFileSync(path.join(__dirname, 'config.json'));
In ES modules, __dirname and __filename do not exist. Use import.meta.dirname and import.meta.filename (Node 20.11+), or derive them from import.meta.url on older versions:
// app.mjs
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const configPath = join(import.meta.dirname, 'config.json'); // modern
// older: path.dirname(fileURLToPath(import.meta.url)) with fileURLToPath from 'node:url'
const config = JSON.parse(readFileSync(configPath, 'utf8'));
Key Takeaways
globalThis(aliasglobal) is the global object; top-levelvarstays module-scoped.- Node provides many web-standard globals (
fetch,URL,AbortController,crypto, Web Streams) in addition toprocessandBuffer. __dirnameis the source file's directory,process.cwd()is where the command was run; use the former for files that ship with the code.- In ES modules, use
import.meta.dirname,import.meta.filenameorimport.meta.urlinstead of__dirnameand__filename.
Practice
- (Beginner) Write a script that prints
process.cwd()and__dirname, then run it from two different directories and explain the output.- (Intermediate) Write a function that works in both browsers and Node.js and uses only shared globals to fetch a JSON URL with a 3-second timeout (hint:
AbortSignal.timeout).- (Interview) "A script reads
./templates/email.htmland works on your laptop but fails in production with ENOENT. What is a likely cause?" Hint: think about how relative paths are resolved and how process managers or Docker set the working directory.
CommonJS vs ES Modules in Node
Node.js has two module systems. CommonJS (CJS) was invented for Node.js in 2009, years before JavaScript had modules. ES modules (ESM), standardised in ES2015 and covered in Chapter 3.4, are the language's official system. Node supports both, and every Node developer meets both, so it is essential to know how they differ and how they interoperate.
CommonJS. Each file is wrapped in a function before it runs, which is where the "magic" variables come from:
// What Node does internally with every CommonJS file (simplified)
(function (exports, require, module, __filename, __dirname) {
// ... your file's code ...
});
// math.cjs — exporting
function add(a, b) { return a + b; }
module.exports = { add, PI: 3.14159 }; // the value returned by require()
// app.cjs — importing
const { add, PI } = require('./math.cjs'); // synchronous: the file is read and executed right here
console.log(add(PI, 1));
ES modules.
// math.mjs
export function add(a, b) { return a + b; }
export const PI = 3.14159;
// app.mjs
import { add, PI } from './math.mjs'; // file extension is mandatory for relative imports
console.log(add(PI, 1));
How Node decides which system a file uses.
| File | Treated as |
|---|---|
.mjs | Always ESM |
.cjs | Always CommonJS |
.js with nearest package.json containing "type": "module" | ESM |
.js with "type": "commonjs" or no type field | CommonJS, though recent versions detect ESM syntax and fall back to ESM |
The main differences.
| Aspect | CommonJS | ES modules |
|---|---|---|
| Syntax | require(), module.exports | import, export |
| Loading | Synchronous, at run time, when require executes | Asynchronous; the module graph is parsed and linked before code runs |
| Static analysis | require(variable) is allowed anywhere, so tools cannot fully analyse it | Imports are static, enabling tree-shaking; import() for dynamic loading |
| Exports | A copy of module.exports at the time of the call | Live bindings: importers see later updates |
Top-level await | Not allowed | Allowed |
this at top level | module.exports | undefined |
__dirname, require | Available | Not available (import.meta.dirname, createRequire) |
| Strict mode | Opt-in | Always on |
| Extension in relative paths | Optional (./math) | Required (./math.js) |
| JSON | require('./data.json') | import data from './data.json' with { type: 'json' } |
Interoperability.
- ESM importing CJS always works:
module.exportsbecomes the default export, and Node tries to detect named exports. - CJS importing ESM used to be possible only with dynamic
import(), which returns a promise. Since Node 22.12 (and 20.19),require()can load ES modules synchronously, as long as they do not use top-levelawait.
// app.cjs — loading ESM from CommonJS
const { add } = require('./math.mjs'); // Node 22.12+: works if math.mjs has no top-level await
async function main() {
const { add } = await import('./math.mjs'); // works everywhere, but asynchronous
}
// app.mjs — using a CommonJS-only API from ESM
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('./legacy-config.cjs');
Core module specifiers. Use the node: prefix (import fs from 'node:fs', require('node:fs')). It makes it obvious that the module is built in and cannot be confused with an npm package of the same name.
Which to choose. New applications should use ESM ("type": "module"): it is the standard, it works in browsers too, and the ecosystem has largely migrated. Many existing projects and packages remain CommonJS, and libraries often publish both through the exports field of package.json (11.2).
Key Takeaways
- CommonJS uses
require/module.exports, loads synchronously and wraps each file in a function that provides__dirnameand friends. - ESM uses
import/export, is statically analysable, supports top-levelawaitand live bindings, and requires file extensions. - The file extension and the
typefield inpackage.jsondecide which system a file uses. - ESM can import CJS; modern Node can also
require()synchronous ESM;createRequireandimport()bridge the remaining gaps.
Practice
- (Beginner) Convert a two-file CommonJS project (
utils.jsexporting two functions,index.jsusing them) to ES modules, and list every change you had to make.- (Intermediate) Demonstrate the difference between CommonJS export copies and ESM live bindings: export a counter and an
incrementfunction from a module, increment it from the importer, and print the counter in both systems.- (Interview) "Why can't a CommonJS module simply
require()an ES module that uses top-levelawait?" Hint: compare the synchronous return valuerequiremust produce with the moment at which a module with top-levelawaitfinishes evaluating.
Node.js vs Browser Runtime Differences
Node.js and browsers share the same language and often the same engine, but they are different host environments. Code that does not touch host APIs runs in both; code that does must be written for one, or detect where it is running.
What is the same. The ECMAScript language (syntax, Promise, Map, classes, modules), the single-threaded execution model with tasks and microtasks (Chapter 5.1), and a growing set of web-standard APIs: fetch, URL, AbortController, TextEncoder, Web Streams, Web Crypto, structuredClone, EventTarget.
What is different.
| Aspect | Browser | Node.js |
|---|---|---|
| Purpose | Render documents and run untrusted code from any site | Run trusted programs on a machine |
| Global object | window (also globalThis, self) | global (also globalThis) |
DOM, document, CSS | Yes | No (libraries such as jsdom emulate it for tests) |
| File system | No direct access (sandboxed File System Access API with user consent) | Full access via fs |
| Processes, OS info | No | process, child_process, os |
| Network | fetch, WebSocket, WebRTC, restricted by CORS and the same-origin policy | fetch, raw TCP/UDP (net, dgram), servers, no CORS |
| Security model | Sandbox, same-origin policy, permissions prompts | Same privileges as the user running the process (opt-in --permission model) |
| Modules | ESM only, loaded by URL | ESM and CommonJS, resolved from node_modules |
| Event loop | Interleaves tasks with rendering frames | libuv phases (timers, poll, check, ...), no rendering (11.3) |
| Threads | Web Workers | Worker threads (11.5) |
| Binary data | ArrayBuffer, Uint8Array, Blob | Same, plus Buffer |
| Engine version | Chosen by the user | Chosen by you |
The last row matters more than it seems. In the browser you must support the engines your users have, which often means transpiling or polyfilling. In Node.js you pick the version, so you can use the newest language features once your production runtime supports them.
No CORS on the server. CORS (Chapter 10) protects browser users: it stops a malicious page from reading responses from other origins with the user's cookies. A Node process has no such user to protect, so fetch from Node can read any URL. This is why a server-side proxy is a common workaround for APIs that do not send CORS headers.
Writing code for both. Universal ("isomorphic") code should stick to shared APIs and detect the environment only where necessary:
// Feature detection, not user-agent sniffing
const isNode = typeof process !== 'undefined' && process.versions?.node != null;
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
// Prefer standard APIs that work in both
const id = crypto.randomUUID(); // Web Crypto, global in both
const bytes = new TextEncoder().encode('hello'); // Uint8Array in both
const res = await fetch('https://api.example.com/x'); // fetch in both
Packages can also publish different entry points for each environment using conditional exports (11.2):
{
"exports": {
".": {
"browser": "./dist/browser.js",
"node": "./dist/node.js",
"default": "./dist/node.js"
}
}
}
Key Takeaways
- Both run ECMAScript with the same task/microtask model and share many web-standard APIs.
- Node has no DOM, but has full access to files, processes and raw sockets; browsers are sandboxed.
- Node code runs with the user's privileges and without CORS; browsers enforce the same-origin policy.
- You control the Node version, while browser code must support whatever engines visitors use.
Practice
- (Beginner) Take five global identifiers (
document,fetch,process,localStorage,setImmediate) and state whether each exists in browsers, in Node.js or in both.- (Intermediate) Write a small module that computes a SHA-256 hex digest of a string using only
crypto.subtleandTextEncoder, and run it unchanged in a browser console and in Node.js.- (Interview) "Calling a third-party API from the browser fails with a CORS error, but the same call works from Node.js. Why, and what are the correct fixes?" Hint: consider whom CORS protects, where the check happens, and the difference between configuring the API server and proxying through your back end.
Alternative Runtimes: Deno and Bun
For a decade "server-side JavaScript" meant Node.js. Since 2020 two serious alternatives have appeared, both trying to fix things their authors saw as historical mistakes or performance limits in Node. They also pushed Node itself to add features faster (built-in test runner, watch mode, TypeScript support).
Deno. Created by Ryan Dahl, the original author of Node.js, after his 2018 talk "10 Things I Regret About Node.js". Its design goals:
- Secure by default: a script cannot read files, access the network or environment variables unless granted with flags such as
--allow-reador--allow-net. - Web standards first:
fetch, Web Streams and ES modules everywhere; modules can be imported by URL. - Built-in TypeScript and an all-in-one toolchain: formatter, linter, test runner, bundler-like
deno compile. - Built on V8 and Rust (the Tokio async runtime instead of libuv).
Deno 2 (2024) added broad compatibility with Node.js and npm (npm: specifiers, package.json support), because ignoring the npm ecosystem proved impractical.
Bun. Created by Jarred Sumner, released as 1.0 in 2023. Its focus is speed and being an all-in-one, drop-in replacement for Node.js:
- Uses JavaScriptCore (Safari's engine) instead of V8, and is written in Zig.
- Very fast start-up, a fast package manager (
bun install), test runner, bundler and native TypeScript/JSX execution. - Aims to implement Node's APIs (
fs,http,node:modules) so existing projects run unchanged, plus its own fast APIs (Bun.serve,Bun.file, built-in SQLite).
Comparison.
| Aspect | Node.js | Deno | Bun |
|---|---|---|---|
| Engine | V8 | V8 | JavaScriptCore |
| Implementation | C++ (libuv) | Rust (Tokio) | Zig |
| Security | Full access by default; opt-in --permission model | Permissions required by default | Full access by default |
| TypeScript | Type stripping in recent versions | Built in (with type checking via deno check) | Built in (transpiled) |
| Package management | npm (yarn, pnpm) | URLs, JSR, npm: specifiers, package.json | bun install (npm-compatible) |
| Tooling | External tools plus built-in test runner | All-in-one | All-in-one |
| Ecosystem maturity | Very mature, the reference implementation | Good, Node compatibility layer | Good, fast-moving, some compatibility gaps |
| Governance | OpenJS Foundation | Deno Land Inc. | Oven (acquired by Anthropic in 2025) |
The same server in three runtimes. All three converge on the web-standard Request/Response model:
// Deno: deno run --allow-net server.ts
Deno.serve({ port: 3000 }, (req) => new Response('Hello from Deno'));
// Bun: bun server.ts
Bun.serve({ port: 3000, fetch: (req) => new Response('Hello from Bun') });
// Node.js: node server.js (the http module is callback-based, 11.6)
import { createServer } from 'node:http';
createServer((req, res) => res.end('Hello from Node')).listen(3000);
WinterTC. A standards committee at Ecma (formerly the WinterCG community group) defines a "minimum common API" for server-side runtimes, including edge platforms such as Cloudflare Workers. Code that uses only these APIs (fetch, Request, Response, URL, Web Streams, Web Crypto) is portable across all of them, which is why frameworks such as Hono target that subset.
Choosing. Node.js remains the safe default for production: the longest track record, LTS releases, the widest library compatibility and support from every hosting provider. Deno is attractive when you value the permission model and built-in tooling; Bun when start-up time, install speed or an all-in-one toolkit matters. Many teams use Bun or Deno as tooling (running scripts and tests) while deploying on Node.
Key Takeaways
- Deno (by Node's creator) emphasises security by default, web standards and built-in TypeScript tooling; Deno 2 is largely npm-compatible.
- Bun (JavaScriptCore, Zig) emphasises speed and acts as a drop-in replacement for Node with an integrated toolchain.
- All three are converging on web-standard APIs, formalised by WinterTC, which makes portable server code possible.
- Node.js is still the conservative production choice because of maturity, LTS and ecosystem compatibility.
Practice
- (Beginner) Install Deno and run a script that reads a file without
--allow-read. Describe what happens, then grant only the specific file and run it again.- (Intermediate) Write a minimal HTTP handler using only
RequestandResponsethat returns JSON for/api/timeand 404 otherwise, then run it on Deno, Bun and Node.js (for Node, adapt it with a small wrapper or a library such as@hono/node-server).- (Interview) "Would you migrate an existing production Node.js service to Bun for performance?" Hint: consider where the time is actually spent (I/O, database, CPU), compatibility risks with native add-ons and Node APIs, operational support, and how you would measure the benefit first.
<a id="112-the-npm-package-manager"></a>
11.2 The npm Package Manager
This subchapter covers npm, the package manager bundled with Node.js: the registry, the package.json manifest, dependency types and version ranges, lock files, scripts, publishing, monorepos, supply-chain security and the alternative managers yarn and pnpm.
The npm Registry and Package Ecosystem
Before package managers, reusing someone else's library meant downloading a ZIP file, copying it into your project and repeating the process by hand for every update and every dependency of that library. A package manager automates this: you declare what you need, and it finds, downloads, verifies and installs the right versions, along with everything they depend on.
Three things called "npm".
| Name | What it is |
|---|---|
| The npm registry | A public database of packages at https://registry.npmjs.org, run by npm Inc. (owned by GitHub/Microsoft) |
| The npm CLI | The npm command installed with Node.js |
| npmjs.com | The website for searching packages and managing accounts and organisations |
The registry is the largest software package registry in the world, with millions of packages. A package is a directory (published as a tarball) containing a package.json file; a module is anything Node can load with require or import. Most packages are modules, but some only provide command-line tools.
What happens during npm install express.
1. Resolve ask the registry for express's metadata (GET registry.npmjs.org/express)
choose the newest version matching the requested range
2. Recurse read its dependencies and resolve each one, building a dependency tree
3. Dedupe hoist compatible versions to the top of node_modules to avoid duplicates
4. Fetch download tarballs (or take them from the local cache, ~/.npm/_cacache)
5. Verify check each tarball against its integrity hash (sha512)
6. Extract unpack into node_modules/
7. Scripts run lifecycle scripts such as postinstall (unless disabled)
8. Record write package.json (if adding) and package-lock.json
How Node finds packages. When code calls require('express') or import 'express', Node looks for a node_modules/express directory in the current folder, then in the parent folder, and so on up to the root of the file system. This simple algorithm is why node_modules is local to each project.
project/
├── package.json
├── package-lock.json
├── node_modules/
│ ├── express/ <- direct dependency
│ ├── body-parser/ <- dependency of express, hoisted to the top level
│ └── debug/
│ └── node_modules/
│ └── ms/ <- nested because another package needs a different ms version
└── src/
└── app.js <- require('express') walks up and finds ../node_modules/express
The small-module culture. The npm ecosystem favours many small packages. This makes reuse easy, but trees grow quickly: a typical web application can have over a thousand transitive dependencies. The 2016 left-pad incident, when an 11-line package was unpublished and broke builds worldwide, led npm to restrict unpublishing; later incidents (see "Auditing and Supply Chain Risks" below) showed the security cost of large trees.
Evaluating a package before adding it. Check the weekly downloads, the date of the last release, open issues, the number of maintainers, its own dependency count, its licence and whether it ships TypeScript types. Tools like npm view <pkg> and npm explain <pkg> help from the command line.
npm search markdown parser # search the registry
npm view express versions --json # all published versions
npm view express dependencies # what installing it pulls in
npm ls # the installed tree of the current project
npm explain ms # why is this package installed? which packages depend on it?
Key Takeaways
- npm is three things: a public registry, a command-line client and a website.
- Installing resolves version ranges, builds a dependency tree, downloads and verifies tarballs, and writes
node_modulesand a lock file. - Node resolves bare package names by walking up the directory tree looking for
node_modules. - Small-module culture makes dependency trees large; evaluate packages before adding them.
Practice
- (Beginner) Create an empty project, install
express, and count how many packages end up innode_modules(npm ls --allorls node_modules | wc -l).- (Intermediate) Use
npm explainon a transitive dependency of a real project and draw the chain of packages that leads to it.- (Interview) "What criteria do you use before adding a new dependency to a production project?" Hint: think about maintenance, security history, size of its own tree, licence, alternatives in the standard library, and the cost of removing it later.
package.json Anatomy
Every Node.js project and every npm package has a package.json at its root. It is the project's identity card and manifest: it says what the project is called, how to run it, what it depends on and, for packages, what files to expose to users.
Creating one.
npm init # interactive questions
npm init -y # accept the defaults
npm pkg set type=module scripts.start="node src/index.js" # edit fields from the CLI
An annotated example for an application.
{
"name": "order-service", // lowercase, URL-safe; must be unique on the registry if published
"version": "1.4.2", // semantic version (next topics)
"private": true, // refuse to publish by accident; typical for applications
"type": "module", // .js files are ES modules
"description": "REST API for orders",
"license": "MIT",
"engines": { "node": ">=22.12" }, // supported Node versions
"scripts": { // commands run with `npm run <name>`
"start": "node src/index.js",
"dev": "node --watch --env-file=.env src/index.js",
"test": "node --test"
},
"dependencies": { // needed at run time
"express": "^5.1.0"
},
"devDependencies": { // needed only for development and builds
"typescript": "~5.9.2"
}
}
(package.json itself must be strict JSON; the comments above are for explanation only.)
Additional fields for a library.
| Field | Purpose |
|---|---|
main | Legacy entry point for require('pkg') |
exports | Modern, explicit map of entry points; hides everything not listed |
types | Entry point for TypeScript declarations |
bin | Command-line executables to install ({ "mytool": "./bin/cli.js" }) |
files | Allow-list of files to include in the published tarball |
repository, homepage, bugs | Links shown on npmjs.com |
keywords, author, contributors | Metadata for search and credit |
peerDependencies | Packages the host project must provide (next topic) |
sideEffects | Tells bundlers which files are safe to tree-shake |
The exports field. It defines the package's public interface and can serve different files depending on conditions such as import vs require or node vs browser:
{
"name": "tiny-date",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./format": "./dist/format.js",
"./package.json": "./package.json"
}
}
With this map, import 'tiny-date' and import 'tiny-date/format' work, while import 'tiny-date/dist/internal.js' fails with ERR_PACKAGE_PATH_NOT_EXPORTED. That protection lets authors refactor internals without breaking users. Conditions are matched in object order, so types comes first and default (if present) last.
Subpath imports. The related imports field defines private aliases (they must start with #) usable only inside the package, which replaces fragile ../../../ paths:
{ "imports": { "#db": "./src/db/index.js", "#utils/*": "./src/utils/*.js" } }
import { pool } from '#db';
import { slugify } from '#utils/strings';
Key Takeaways
package.jsondescribes the project: name, version, module type, scripts, dependencies and supported engines."private": trueprevents accidental publishing of applications.exportsdefines a package's public entry points and conditional variants (import,require,types,browser); unlisted paths are inaccessible.importsprovides internal#aliases;bininstalls commands;filescontrols what is published.
Practice
- (Beginner) Run
npm init -y, then usenpm pkg setto add astartscript, settypetomoduleand add anenginesfield. Inspect the resulting file.- (Intermediate) Create a local package with an
exportsmap exposing.and./math, link it into another project withnpm install ../my-lib, and verify that importing an unexported internal file fails.- (Interview) "What problems does the
exportsfield solve compared withmain?" Hint: think about encapsulation of internal files, dual CommonJS/ESM packages, TypeScript types and environment-specific builds.
Dependencies, devDependencies and peerDependencies
Not every package a project uses is needed in the same situations. A web framework must be present in production; a test runner is needed only on developers' machines and in CI; a React component library needs React but must not bring its own copy. npm expresses these situations with separate dependency fields.
The dependency fields.
| Field | Meaning | Installed when | Example |
|---|---|---|---|
dependencies | Required at run time | Always, including when your package is installed by others | express, pg, zod |
devDependencies | Needed only to develop, test or build | In your project only; skipped with npm install --omit=dev and never installed for consumers of your package | typescript, vitest, eslint |
peerDependencies | Must be provided by the host project; your package plugs into it | npm 7+ installs missing peers automatically and errors on conflicts | A React library requires react |
optionalDependencies | Nice to have; installation failures are ignored | When possible | Platform-specific native binaries |
bundleDependencies | Shipped inside your tarball | With your package | Rare: offline or patched copies |
npm install express # -> dependencies
npm install -D typescript # -> devDependencies (--save-dev)
npm install -O fsevents # -> optionalDependencies
npm uninstall lodash # remove from package.json and node_modules
npm install --omit=dev # production install: skip devDependencies
Why peer dependencies exist. Imagine a plugin for a framework. If the plugin listed the framework under dependencies, the application could end up with two copies of it: its own and the plugin's nested copy. For libraries with internal state or identity checks (React hooks, instanceof checks), two copies cause subtle bugs such as "Invalid hook call".
Wrong: plugin lists react in dependencies Right: plugin lists react in peerDependencies
app/node_modules/ app/node_modules/
├── react@19 <- app's copy ├── react@19 <- single shared copy
└── ui-kit/ └── ui-kit/ <- uses the app's react
└── node_modules/
└── react@18 <- second copy: two React instances, hooks break
{
"name": "ui-kit",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react-dom": { "optional": true }
},
"devDependencies": {
"react": "^19.1.0"
}
}
The library also lists React under devDependencies so its own tests can run. Keep peer ranges wide: the narrower the range, the more likely it is to conflict with the host. When peers conflict, npm reports ERESOLVE; --legacy-peer-deps skips peer installation and should be a temporary workaround, not a fix.
Applications vs libraries. For an application deployed from source, the split between dependencies and devDependencies mainly affects the size of production installs and Docker images. For a published library it is critical: everything in dependencies is installed on every user's machine.
Overrides. Sometimes a transitive dependency has a bug or vulnerability and the intermediate package has not updated yet. overrides forces a version throughout the tree:
{
"overrides": {
"semver": "^7.6.3",
"some-lib": { "debug": "4.4.0" }
}
}
(yarn calls this field resolutions; pnpm supports pnpm.overrides.)
Key Takeaways
dependenciesare needed at run time;devDependenciesonly for development and builds and are never installed for consumers.peerDependenciesdeclare that the host must supply a package, preventing duplicate copies of frameworks.- Keep peer ranges wide and duplicate peers in
devDependenciesfor local testing. overridespins a transitive dependency version when the direct dependency cannot be updated.
Practice
- (Beginner) Classify each of these for a TypeScript Express API:
express,typescript,@types/express,pino,vitest,dotenv,tsx.- (Intermediate) Build a Docker image with
npm ciand withnpm ci --omit=dev, and compare the sizes ofnode_modules.- (Interview) "Why should a React component library declare React as a peer dependency rather than a regular dependency?" Hint: think about how many copies of React end up in the application bundle and what React's hooks rely on.
Semantic Versioning and Version Ranges
When a package releases a new version, consumers need to know whether upgrading is safe. Semantic Versioning (SemVer) encodes this promise in the version number itself, and npm's version ranges let a project accept safe updates automatically while refusing risky ones.
The SemVer contract. A version is MAJOR.MINOR.PATCH, optionally followed by a pre-release tag and build metadata:
2 . 7 . 4 - beta.1 + build.5
| | | | |
| | | | +-- build metadata (ignored for precedence)
| | | +------------ pre-release: 2.7.4-beta.1 < 2.7.4
| | +----------------- PATCH: backwards-compatible bug fixes
| +--------------------- MINOR: backwards-compatible new features
+------------------------- MAJOR: breaking changes
Versions 0.x.y are special: the API is considered unstable, and by convention a MINOR change (0.3 to 0.4) can be breaking.
Range syntax used in package.json.
| Range | Meaning | Accepts | Rejects |
|---|---|---|---|
4.18.2 | Exactly this version | 4.18.2 | everything else |
^4.18.2 | Compatible: same major (default for npm install) | 4.18.2, 4.19.0, 4.99.9 | 5.0.0, 4.18.1 |
^0.3.1 | For 0.x, caret locks the minor | 0.3.1, 0.3.9 | 0.4.0 |
~4.18.2 | Patch updates only | 4.18.2, 4.18.9 | 4.19.0 |
>=4.0.0 <5.0.0 | Explicit comparator range | as written | |
4.x or 4 | Any 4.x.y | 4.0.0, 4.20.1 | 5.0.0 |
1.2.3 - 1.4.0 | Inclusive hyphen range | 1.2.3 to 1.4.0 | |
^1.0.0 || ^2.0.0 | Either range | 1.5.0, 2.1.0 | 3.0.0 |
* or "" | Any version | everything | nothing (avoid) |
latest, next | A distribution tag, resolved at install time |
Pre-release versions are only matched by ranges that explicitly mention a pre-release on the same MAJOR.MINOR.PATCH, so ^2.0.0 does not silently install 2.1.0-beta.1.
Trying ranges in code. The semver package (used by npm itself) evaluates ranges:
import semver from 'semver';
semver.satisfies('4.19.1', '^4.18.2'); // true
semver.satisfies('5.0.0', '^4.18.2'); // false
semver.satisfies('0.4.0', '^0.3.1'); // false: caret on 0.x locks the minor
semver.maxSatisfying(['1.2.0', '1.3.5', '2.0.0'], '~1.3.0'); // '1.3.5'
semver.inc('1.4.2', 'minor'); // '1.5.0'
Bumping your own version.
npm version patch # 1.4.2 -> 1.4.3, commits and tags v1.4.3 in git
npm version minor # 1.4.3 -> 1.5.0
npm version major # 1.5.0 -> 2.0.0
npm version prerelease --preid=rc # 2.0.0 -> 2.0.1-rc.0
SemVer is a promise, not a guarantee. Maintainers decide what counts as "breaking", and mistakes happen: a patch release can break your code. Ranges give flexibility, but reproducible builds come from lock files (next topic). Tools such as Renovate or Dependabot propose updates as pull requests so CI can verify them.
Key Takeaways
- SemVer: MAJOR for breaking changes, MINOR for compatible features, PATCH for compatible fixes;
0.xis unstable. ^allows minor and patch updates (only patch updates for0.x),~allows patch updates, an exact version allows nothing.- Pre-releases are excluded from ranges unless explicitly requested.
- Ranges express what is compatible; lock files decide what is actually installed.
Practice
- (Beginner) For each range
^2.3.1,~2.3.1,2.xand^0.2.5, list which of these versions it accepts:2.3.0,2.3.9,2.4.0,3.0.0,0.2.9,0.3.0.- (Intermediate) You maintain a library at 1.6.3. Decide the next version for each change: a bug fix; a new optional parameter; renaming an exported function; dropping support for Node 18. Justify each.
- (Interview) "If
^already restricts updates to compatible versions, why do we still need a lock file?" Hint: consider two developers installing a month apart, a maintainer mistake in a minor release, and transitive dependencies you do not control.
Lock Files and Reproducible Installs
package.json lists ranges, so the same file can produce different node_modules trees on different days as new versions are published. A build that passed yesterday can fail today without any change to your code. Lock files record the exact result of a resolution so every install reproduces it.
What package-lock.json contains. For every package in the tree: the exact version, where it was downloaded from and an integrity hash.
{
"name": "order-service",
"lockfileVersion": 3,
"packages": {
"": {
"name": "order-service",
"dependencies": { "express": "^5.1.0" }
},
"node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-<base64-encoded SHA-512 of the tarball>",
"dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0" }
}
}
}
The integrity field is a Subresource Integrity hash: if the downloaded tarball does not match, installation fails. This protects against corrupted downloads and tampered mirrors.
npm install vs npm ci.
npm install | npm ci | |
|---|---|---|
| Purpose | Development: add, update, resolve | CI and production: reproduce exactly |
| Reads | package.json and the lock file | The lock file (must agree with package.json) |
| Modifies the lock file | Yes, when ranges or the tree change | Never; fails if out of sync |
Existing node_modules | Updated incrementally | Deleted and recreated |
| Speed | Variable | Predictable, cache-friendly |
# On a developer machine
npm install zod # adds zod, updates package.json and package-lock.json
git add package.json package-lock.json
git commit -m "Add zod"
# In CI and Dockerfiles
npm ci # installs exactly what the lock file says, or fails
npm ci --omit=dev # production image
Rules for teams.
- Commit the lock file for applications. Every developer, CI run and deployment then uses identical versions.
- Update dependencies deliberately (
npm update,npm outdated,npm install pkg@latest) and review lock file diffs in pull requests. A large unexpected diff deserves a question. - Resolve merge conflicts in the lock file by resolving
package.jsonfirst and then runningnpm install, which regenerates the lock file; do not edit it by hand. - Libraries should commit their lock file too (for reproducible CI), but note that it is not published: consumers resolve the library's ranges themselves. (
npm-shrinkwrap.jsonis a publishable variant, appropriate only for CLI tools.)
Updating.
npm outdated # current, wanted (max in range) and latest versions
npm update # update within ranges, refresh the lock file
npm install express@latest # move to a new major deliberately
Each package manager has its own format: package-lock.json (npm), yarn.lock, pnpm-lock.yaml, bun.lock. Use one manager per project and commit only its lock file.
Key Takeaways
- A lock file records the exact version, source and integrity hash of every package in the tree.
npm installmay update the lock file;npm ciinstalls exactly what it says and fails if it is out of sync.- Commit the lock file, use
npm ciin CI and production, and review lock file changes. - Library lock files are not published; consumers resolve the library's ranges themselves.
Practice
- (Beginner) Install a package, delete
node_modules, runnpm ci, and confirm withnpm lsthat the same versions are installed.- (Intermediate) Change a dependency range in
package.jsonwithout runningnpm install, then runnpm ciand explain the error. Fix it properly.- (Interview) "A build works locally but fails in CI with an error inside a dependency. How do you investigate?" Hint: compare the installed versions in both environments, check whether CI uses
npm ciand the committed lock file, and look at Node and npm versions.
npm Scripts and Task Automation
Projects need repeatable commands: start the server, run tests, build, lint, format. Instead of documenting long command lines in a README, put them in the scripts field of package.json so everyone (and CI) runs them the same way with npm run <name>.
Basics.
{
"scripts": {
"dev": "node --watch --env-file=.env src/index.js",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js",
"test": "node --test",
"lint": "eslint .",
"format": "prettier --write .",
"check": "npm run lint && npm run build && npm test"
}
}
npm run dev # run any script by name
npm start # shortcuts exist for start, test, stop, restart
npm test
npm run # list available scripts
npm test -- --test-reporter=spec # arguments after -- are passed to the script's command
Why local tools "just work". When npm runs a script, it adds node_modules/.bin to the PATH. Packages that declare a bin field install executables there, so "lint": "eslint ." uses the project's own ESLint version, not a global one. Scripts also receive environment variables such as npm_package_name and npm_package_version.
Pre and post scripts. A script named preX runs before X, and postX after it:
{
"scripts": {
"prebuild": "rm -rf dist",
"build": "tsc",
"postbuild": "node scripts/copy-assets.js"
}
}
Lifecycle scripts. Some names are run automatically by npm itself:
| Script | When it runs |
|---|---|
prepare | After npm install in the project, and before npm publish and npm pack (often used to build or install git hooks) |
prepublishOnly | Only before npm publish |
preinstall, install, postinstall | When the package is installed, including as someone else's dependency |
Install scripts of dependencies run arbitrary code on your machine during npm install. They are needed for native add-ons, but they are also the most common vector for malicious packages (see "Auditing and Supply Chain Risks" later in this subchapter).
Cross-platform scripts. Scripts run in sh on Unix and cmd.exe on Windows by default, so shell-specific syntax breaks portability:
| Problem | Portable alternative |
|---|---|
rm -rf dist | node -e "fs.rmSync('dist',{recursive:true,force:true})" or the rimraf package |
NODE_ENV=production node app.js | node --env-file=.env.production app.js or the cross-env package |
cmd1 & cmd2 (parallel) | npm-run-all2 / concurrently packages, or npm run a --workspaces |
For complex logic, write a small Node script (scripts/build.mjs) and call it from package.json: it is portable and testable.
node --run. Node 22 added node --run <script>, which executes a script from package.json directly. It starts faster than npm run because it skips npm's own start-up, but intentionally does not run pre/post scripts.
Key Takeaways
scriptsturns project commands into named, shared, documented tasks run withnpm run.node_modules/.binis on thePATHduring scripts, so the project's pinned tool versions are used.preX/postXwrap scripts; lifecycle scripts such asprepareandpostinstallrun automatically.- Write portable scripts; move complex logic into Node files.
Practice
- (Beginner) Add
dev,start,testandlintscripts to a project and run each. Then runnpm runto list them.- (Intermediate) Create a
checkscript that runs lint, type-checking and tests, failing fast if any step fails, and make it work on both Windows and Linux.- (Interview) "Why is it better to run
npm run lintthan to install ESLint globally and runeslint .?" Hint: think about version consistency across developers and CI, and hownode_modules/.binis used.
Global Packages and npx
Some packages are tools you run from the terminal rather than libraries you import: project generators, CLIs for cloud platforms, one-off utilities. npm offers two ways to use them: install them globally, or run them on demand with npx.
Global installs.
npm install -g npm-check-updates # installs into a global prefix, adds `ncu` to the PATH
ncu # now available in every directory
npm ls -g --depth=0 # list global packages
npm root -g # where global packages live
npm uninstall -g npm-check-updates
Global packages have drawbacks: one version is shared by every project, the version is not recorded in any project file, and on some systems global installs need administrator rights (avoid sudo npm install -g; use a version manager, which gives you a user-owned prefix). The modern recommendation is: install project tools locally as devDependencies, and reserve global installs for a few personal utilities.
npx: run without installing permanently. npx <command> first looks for the command in the local project's node_modules/.bin; if it is not there, it downloads the package into a cache, runs it and leaves your project untouched.
npx eslint . # uses the project's local eslint
npx create-vite@latest my-app # run a project generator once, always the latest version
npx -y cowsay "hello" # -y: skip the confirmation prompt
npx --package=typescript tsc --init # the package name differs from the command name
npm exec -- prettier --check . # npm exec is the same mechanism, npx is its shorthand
npm create <name> is a further shortcut: npm create vite@latest runs the create-vite package.
Security note. npx some-package downloads and executes code from the registry. A typo (npx create-reac-app) can run a malicious look-alike package. Check the name, prefer well-known scoped packages, and pin a version (npx tool@1.2.3) in scripts and documentation.
| Approach | Version control | Best for |
|---|---|---|
Local devDependency + npm run / npx | Pinned in package.json and lock file | Tools the project depends on (linters, compilers, test runners) |
npx pkg@version | Specified in the command | One-off generators and occasional utilities |
npm install -g | Not recorded anywhere | Personal tools you use across all projects |
Key Takeaways
- Global installs make a command available everywhere but are unversioned per project; prefer local devDependencies.
npxruns a local binary if present, otherwise downloads and runs the package temporarily.npm create xruns thecreate-xpackage, the standard way to scaffold projects.- Running packages with
npxexecutes remote code: check names and pin versions.
Practice
- (Beginner) Use
npxto runnpm-check-updatesin a project without installing it, and interpret its output.- (Intermediate) Install a tool globally and the same tool at a different version locally. Show which version runs with the bare command, with
npxand inside an npm script.- (Interview) "Should a project's README tell contributors to install the TypeScript compiler globally?" Hint: consider reproducibility, multiple projects on one machine and CI.
Publishing and Scoped Packages
Publishing turns your code into a package anyone (or only your organisation) can install. Doing it well means controlling what goes into the tarball, versioning correctly, and protecting the account and pipeline that publish it.
Scopes. A scope is a namespace that starts with @: @angular/core, @types/node, @acme/logger. Scopes belong to a user or organisation on npm, which prevents name squatting and makes ownership obvious. Scoped packages are private by default (requires a paid plan) unless published with --access public.
{
"name": "@acme/logger",
"version": "1.0.0",
"type": "module",
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
"files": ["dist", "README.md"],
"publishConfig": { "access": "public" },
"repository": { "type": "git", "url": "git+https://github.com/acme/logger.git" }
}
Before publishing: inspect the tarball. Accidentally published .env files and private keys are a real and recurring problem. Use the files allow-list rather than an .npmignore deny-list, and check the result:
npm pack --dry-run # lists every file that would be included, and the tarball size
npm pack # creates acme-logger-1.0.0.tgz for local testing
npm install ../logger/acme-logger-1.0.0.tgz # test it in another project like a real user
Publishing.
npm login # authenticate (use 2FA)
npm version minor # bump version, commit and tag
npm publish # upload; runs prepublishOnly/prepare first
npm publish --tag next # publish a pre-release without moving the `latest` tag
npm dist-tag add @acme/logger@2.0.0 latest # promote it later
npm deprecate @acme/logger@"<1.2.0" "Security fix in 1.2.0, please upgrade"
A published version is immutable: you cannot overwrite 1.0.0, only publish 1.0.1. Unpublishing is allowed only within 72 hours and only under conditions (for example, no other package depends on it), to avoid another left-pad.
Publishing from CI securely. Long-lived publish tokens stored in CI secrets are attractive to attackers. Current best practice:
- Trusted publishing (OIDC): npm trusts a specific GitHub Actions or GitLab workflow to publish without any stored token.
- Provenance:
npm publish --provenanceattaches a signed statement linking the tarball to the source commit and build workflow, shown on npmjs.com. - Enforce 2FA on maintainer accounts and organisations.
# .github/workflows/publish.yml (excerpt)
permissions:
id-token: write # allow the workflow to request an OIDC token for trusted publishing
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 24, registry-url: 'https://registry.npmjs.org' }
- run: npm ci
- run: npm test
- run: npm publish --provenance --access public
Private registries. Companies host internal packages on GitHub Packages, GitLab, Artifactory, Verdaccio or npm organisations. An .npmrc file maps scopes to registries:
# .npmrc
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
Always use a scope for internal packages. An unscoped internal name (acme-utils) can be hijacked by someone publishing the same name publicly, the dependency confusion attack.
Key Takeaways
- Scopes (
@org/name) namespace packages by owner and are private by default; use--access publicfor open source. - Use
filesandnpm pack --dry-runto control and verify exactly what is published. - Versions are immutable; use dist-tags (
latest,next) for release channels andnpm deprecateto steer users. - Publish from CI with trusted publishing and provenance; use scoped names for internal packages to prevent dependency confusion.
Practice
- (Beginner) Run
npm pack --dry-runon a project without afilesfield and identify files that should not be published.- (Intermediate) Set up a local Verdaccio registry, publish a scoped package to it, and install it from another project using an
.npmrcscope mapping.- (Interview) "What is a dependency confusion attack and how do you protect an organisation against it?" Hint: consider how a package manager chooses between a public and a private registry for the same unscoped name.
Monorepos and Workspaces
As a product grows, it often consists of several related packages: a web front end, an API, a shared validation library, a design system. Keeping them in separate repositories makes cross-cutting changes painful (change the library, publish, bump the version in each consumer). A monorepo keeps them in one repository, and workspaces let the package manager treat them as one project.
Layout.
acme/
├── package.json <- root: "workspaces": ["packages/*", "apps/*"]
├── package-lock.json <- one lock file for everything
├── node_modules/ <- shared, hoisted dependencies
│ └── @acme/
│ └── validation -> ../../packages/validation (symlink)
├── packages/
│ └── validation/
│ └── package.json <- "name": "@acme/validation"
└── apps/
├── api/
│ └── package.json <- depends on "@acme/validation": "*"
└── web/
└── package.json <- depends on "@acme/validation": "*"
{
"name": "acme",
"private": true,
"workspaces": ["packages/*", "apps/*"],
"scripts": {
"build": "npm run build --workspaces --if-present",
"test": "npm test --workspaces --if-present"
}
}
How it works. npm install at the root installs every workspace's dependencies into a single node_modules and symlinks each workspace package there. When apps/api imports @acme/validation, Node resolves it to the local source folder: changes are visible immediately, with no publishing.
npm install # install everything
npm install zod -w @acme/validation # add a dependency to one workspace
npm run test -w apps/api # run a script in one workspace
npm run build --workspaces # run in all workspaces
npm ls @acme/validation # check the link
Benefits and costs.
| Benefits | Costs |
|---|---|
| Atomic changes across packages in one commit and pull request | Larger repository, slower clones and CI unless optimised |
| Single version of shared tooling and dependencies | Requires discipline in boundaries and ownership |
| Easy local development without publishing | Running every test on every change becomes slow |
| One place for CI, linting and formatting rules | Package managers' hoisting can hide undeclared dependencies |
Build orchestration. Workspaces handle installation and linking, but not task ordering or caching. Tools such as Turborepo and Nx add a task graph (build validation before api), cache results by input hash, and run only tasks affected by a change. Changesets manages versioning and changelogs when workspace packages are published.
// turbo.json: build depends on dependencies' builds; outputs are cached
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"test": { "dependsOn": ["build"] }
}
}
Key Takeaways
- A monorepo keeps related packages in one repository; workspaces make the package manager install and link them together.
- Workspace packages are symlinked into the root
node_modules, so local changes are visible without publishing. - Use
-w <name>and--workspacesto target one or all packages. - Add Turborepo or Nx for task ordering and caching, and Changesets for coordinated versioning.
Practice
- (Beginner) Create a monorepo with workspaces
packages/greetandapps/cli, where the CLI imports a function fromgreet. Verify that editinggreetimmediately affects the CLI.- (Intermediate) Add a
buildstep to both workspaces (for example withtsc) and configure a root script that builds them in dependency order.- (Interview) "What are the trade-offs between a monorepo and separate repositories for a front end, a back end and a shared library?" Hint: consider atomic changes, CI time, access control, release independence and tooling requirements.
Auditing and Supply Chain Risks
When you install a package, you run code written by strangers, plus code written by everyone they depend on. A typical application trusts hundreds of maintainers. Attackers know this: compromising one popular package reaches thousands of companies at once. This is called a software supply chain attack.
How attacks happen.
| Attack | Description | Real examples |
|---|---|---|
| Compromised maintainer account | Phished or reused credentials let an attacker publish a malicious version | ua-parser-js (2021); chalk, debug and other widely used packages via a phished maintainer (September 2025) |
| Malicious new maintainer | An attacker gains trust, takes over maintenance and adds a payload | event-stream targeting a Bitcoin wallet (2018) |
| Self-propagating worm | Malware steals npm tokens from installs and republishes itself in the victim's packages | "Shai-Hulud" (2025) |
| Typosquatting | Packages with names close to popular ones (crossenv vs cross-env) | Hundreds removed every year |
| Dependency confusion | Public package with the same name as a company's internal package | Demonstrated against major tech companies (2021) |
| Protestware / sabotage | A maintainer deliberately breaks their own package | colors and faker (2022), node-ipc (2022) |
Most payloads run in install scripts (postinstall) or on first import, and try to steal environment variables, cloud credentials, SSH keys, npm tokens or cryptocurrency.
npm audit: known vulnerabilities. npm compares your lock file against the GitHub Advisory Database:
npm audit # report known vulnerabilities by severity, with dependency paths
npm audit --omit=dev # only what ships to production
npm audit fix # apply compatible (in-range) upgrades
npm audit fix --force # allow major upgrades: may break things, review carefully
npm audit signatures # verify registry signatures and provenance attestations
Read reports critically. A "critical" ReDoS issue in a dev-only build tool that never sees untrusted input is far less urgent than a moderate issue in your HTTP request parser. npm audit also only knows about reported vulnerabilities; it cannot detect a malicious version published an hour ago.
Defensive practices.
- Fewer dependencies. Prefer the standard library: Node now has
fetch,util.parseArgs,fs.glob, the test runner,crypto.randomUUID()andstructuredClone, which replace many small packages. - Lock and reproduce. Commit lock files; use
npm ciso CI installs exactly what was reviewed. - Delay new versions. Many attacks are detected within hours or days. Tools such as Renovate can wait a few days (a "minimum release age") before proposing updates; pnpm has a similar setting.
- Restrict install scripts.
npm ci --ignore-scripts(orignore-scripts=truein.npmrc) blocks them; pnpm 10 and Bun run dependency scripts only for an allow-list of trusted packages. - Protect secrets. Do not keep long-lived tokens in environment variables of developer machines and CI jobs that install packages; use OIDC and short-lived credentials.
- Monitor. Dependabot or Renovate for updates, GitHub security alerts, and specialised scanners (Socket, Snyk) that look at behaviour of new versions (network access, install scripts, obfuscated code).
- Software Bill of Materials (SBOM).
npm sbom --sbom-format cyclonedxlists every component, so you can answer "are we affected?" quickly when a new incident is announced.
# .npmrc for a security-conscious project
ignore-scripts=true # no lifecycle scripts; `npm run x` still runs x, but not prex/postx
audit-level=high # npm audit exits with an error only for high and critical findings
save-exact=true # record exact versions instead of ^ ranges for new dependencies
Key Takeaways
- Every dependency is code you trust; supply-chain attacks target popular packages and maintainer accounts to reach many victims.
- Common vectors are compromised accounts, typosquatting, dependency confusion and malicious install scripts.
npm auditfinds known vulnerabilities in the locked tree; triage by real exposure, not only severity labels.- Reduce dependencies, lock versions, delay fresh releases, restrict install scripts, protect tokens and keep an SBOM.
Practice
- (Beginner) Run
npm auditon an existing project, pick one finding, and trace the dependency path to the vulnerable package. Decide whether it affects production.- (Intermediate) Configure a project with
ignore-scripts=true, install a package that needs a build step (for example a native add-on), and work out how to allow just that package's script safely.- (Interview) "A popular package you depend on was just reported to contain malware in its latest version. What do you do in the next hour?" Hint: determine whether you installed the affected version (lock files, SBOM, CI logs), what the payload could access, which credentials must be rotated, and how to pin a safe version.
Alternative Managers: yarn and pnpm
npm is the default, but not the only client for the npm registry. yarn and pnpm were created to fix npm's historical weaknesses (speed, determinism, disk usage) and pushed npm to improve. All three install the same packages from the same registry; they differ in how they lay out node_modules and in their extra features.
yarn. Released by Facebook in 2016, when npm was slow and had no lock file. It introduced yarn.lock, parallel downloads and an offline cache, ideas npm 5 adopted in 2017. Yarn 2+ ("Berry") is a rewrite with:
- Plug'n'Play (PnP): no
node_modulesat all; a.pnp.cjsfile maps package names to zip archives in a cache. Installs are near-instant and undeclared dependencies fail loudly, but some tools need adapters. nodeLinker: node-modulesto fall back to a traditional layout.- Strong workspace support, constraints and plugins.
pnpm. "Performant npm" uses a content-addressable store: every version of every file is stored once on disk (for example in ~/.local/share/pnpm/store), and projects get hard links to it. Its node_modules is strict: only declared dependencies are visible at the top level, and everything else lives in node_modules/.pnpm and is wired together with symlinks.
npm (hoisted, flat) pnpm (strict, symlinked)
node_modules/ node_modules/
├── express/ ├── express -> .pnpm/express@5.1.0/node_modules/express
├── body-parser/ <- importable even └── .pnpm/
├── debug/ though app never ├── express@5.1.0/node_modules/
└── ... declared it │ ├── express/ (hard links to store)
("phantom dependency") │ └── body-parser -> ../../body-parser@2.2.0/...
└── body-parser@2.2.0/node_modules/body-parser/
The phantom dependency problem is worth understanding: with npm's flat layout, your code can require('debug') without declaring it, because Express happened to install it. It works until Express drops debug, then breaks mysteriously. pnpm prevents this by design.
Comparison.
| Aspect | npm | yarn (Berry) | pnpm |
|---|---|---|---|
| Ships with Node | Yes | No (via Corepack or install) | No (via Corepack or install) |
| Lock file | package-lock.json | yarn.lock | pnpm-lock.yaml |
| Layout | Flat, hoisted node_modules | Plug'n'Play or node_modules | Strict symlinked node_modules |
| Disk usage across projects | One copy per project | Shared cache (zips) | One copy per machine (hard links) |
| Phantom dependencies | Possible | Prevented with PnP | Prevented |
| Workspaces | Yes | Yes, rich features | Yes, very popular for monorepos (pnpm-workspace.yaml) |
| Dependency install scripts | Run by default | Run by default | Allow-list required (pnpm 10+) |
Commands side by side.
| Task | npm | yarn | pnpm |
|---|---|---|---|
| Install all | npm install | yarn | pnpm install |
| CI install | npm ci | yarn install --immutable | pnpm install --frozen-lockfile |
| Add | npm install zod | yarn add zod | pnpm add zod |
| Add dev | npm install -D vitest | yarn add -D vitest | pnpm add -D vitest |
| Run script | npm run build | yarn build | pnpm build |
| Run binary once | npx tool | yarn dlx tool | pnpm dlx tool |
Pinning the manager. The packageManager field records which manager and version a project uses, and Corepack (or the manager itself) enforces it:
{ "packageManager": "pnpm@10.15.0" }
Choosing. npm is the zero-setup default and perfectly adequate for most projects. pnpm is a strong choice for monorepos and large projects because of speed, disk savings and strictness. Yarn PnP suits teams that want the strictest guarantees and are willing to configure tools for it. Bun's installer (bun install) is another fast, npm-compatible option. Whatever you pick, use one manager per repository and commit only its lock file.
Key Takeaways
- yarn and pnpm use the same registry as npm but differ in install layout, speed and features.
- pnpm's content-addressable store saves disk space and its strict layout prevents phantom dependencies.
- Yarn Berry's Plug'n'Play removes
node_modulesentirely in favour of a resolution map. - Pin the manager with
packageManager, use one per project and commit only its lock file.
Practice
- (Beginner) Install the same project with npm and with pnpm in two copies of the directory, and compare install time and the structure of
node_modules.- (Intermediate) Create a project that imports a transitive dependency it does not declare. Show that it works with npm and fails with pnpm, then fix it correctly.
- (Interview) "What is a phantom dependency and why can it break a working application after an unrelated update?" Hint: think about hoisting in a flat
node_modulesand what happens when a direct dependency changes its own dependencies.
<a id="113-asynchronous-and-non-blocking-io"></a>
11.3 Asynchronous and Non-Blocking I/O
This subchapter explains the core of Node's performance model: why blocking I/O is a problem on a single thread, how libuv's event loop schedules work in phases, what the thread pool does, how process.nextTick and setImmediate fit in, the three styles of asynchronous APIs, the difference between concurrency and parallelism, and how to detect code that blocks the loop. It builds on the browser event loop of Chapter 5.1.
Blocking vs Non-Blocking I/O
I/O (reading files, querying databases, calling other services) is slow compared with the CPU. A modern CPU executes billions of instructions per second; a network round trip to a database in the same data centre takes around half a millisecond, enough time for a million instructions. What a program does while waiting determines how many clients it can serve.
| Operation | Typical latency | In "CPU time" terms |
|---|---|---|
| L1 cache reference | ~1 ns | 1 second |
| Main memory reference | ~100 ns | ~2 minutes |
| Read 4 KB from an SSD | ~20-100 µs | ~6-28 hours |
| Round trip within a data centre | ~500 µs | ~6 days |
| Round trip across continents | ~150 ms | ~5 years |
Blocking I/O. A blocking call does not return until the operation is complete. The thread that made the call can do nothing else in the meantime. With one thread per request, that is acceptable: other requests have their own threads. In Node.js, where all JavaScript runs on one thread, a blocking call freezes the entire server.
Non-blocking I/O. A non-blocking call starts the operation and returns immediately. The result is delivered later, through a callback, a promise or an event, when the event loop picks up the completion.
const fs = require('node:fs');
// Blocking: the thread waits inside readFileSync; nothing else can run
console.log('1. before');
const data = fs.readFileSync('big.log', 'utf8');
console.log('2. read', data.length, 'chars');
console.log('3. after');
// Output: 1, 2, 3 — and during the read, every other client of this process waits too
// Non-blocking: readFile returns immediately; the callback runs later
console.log('A. before');
fs.readFile('big.log', 'utf8', (err, data) => {
if (err) throw err;
console.log('C. read', data.length, 'chars');
});
console.log('B. after');
// Output: A, B, C — the thread was free between B and C
The effect on a server.
Blocking handler, 3 requests, each needs 100 ms of I/O and 1 ms of CPU
time -> 0 100 200 300 ms
req 1 [CPU][-------I/O------]
req 2 [CPU][-------I/O------]
req 3 [CPU][-------I/O------]
last response after ~303 ms; the thread is idle 99% of the time
Non-blocking handler, same 3 requests
req 1 [CPU][-------I/O------][cb]
req 2 [CPU][-------I/O------][cb]
req 3 [CPU][-------I/O------][cb]
last response after ~104 ms; I/O waits overlap
When synchronous APIs are fine. Node offers *Sync variants of many functions on purpose. They are simpler and appropriate where nothing else needs to run concurrently: start-up code (reading configuration before the server starts listening), command-line tools and build scripts. Inside request handlers they are almost always a mistake.
Key Takeaways
- I/O is orders of magnitude slower than computation, so what a thread does while waiting matters.
- Blocking calls stop the only JavaScript thread, freezing all other work in the process.
- Non-blocking calls start the operation and return; results arrive later via callbacks, promises or events.
- Synchronous APIs are fine at start-up and in scripts, but not in code that serves concurrent requests.
Practice
- (Beginner) Write a script that reads a large file once with
readFileSyncand once withreadFile, logging messages before and after each call, and explain the output order.- (Intermediate) Create an HTTP server where
/slowusesreadFileSyncon a large file and/fastreturns "ok". Useautocannonor a loop ofcurlrequests to show how/slowaffects/fast, then fix it.- (Interview) "Is it ever acceptable to use
fs.readFileSyncin a Node.js server?" Hint: distinguish start-up time from request time, and consider what else the process is doing at that moment.
The Node Event Loop Phases
The browser event loop (Chapter 5.1) picks one task, runs it, drains microtasks and possibly renders. Node.js has no rendering, but it has many kinds of events: timers, socket data, completed file reads, setImmediate callbacks, closing handles. libuv organises them into phases that run in a fixed order. Each iteration of the loop is called a tick.
The phases.
┌───────────────────────────┐
┌───────> │ timers │ setTimeout / setInterval callbacks whose time has come
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ some I/O callbacks deferred from the previous tick
│ └─────────────┬─────────────┘ (e.g. certain TCP errors)
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ internal use only
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ retrieve new I/O events and run their callbacks;
│ │ │ wait here for I/O if nothing else is scheduled
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ setImmediate callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──────── │ close callbacks │ 'close' events, e.g. socket.on('close')
└───────────────────────────┘
Between every individual callback in every phase:
1. drain the process.nextTick queue
2. drain the promise microtask queue
(repeat until both are empty)
What each phase does.
| Phase | Runs | Notes |
|---|---|---|
| timers | Expired setTimeout/setInterval callbacks | A timer's delay is a minimum, not a guarantee |
| pending callbacks | System-level callbacks deferred from the previous iteration | Rarely relevant in application code |
| poll | Callbacks for completed I/O (incoming data, finished file reads) | If no timers or immediates are pending, the loop blocks here waiting for I/O, using no CPU |
| check | setImmediate callbacks | Runs right after poll, which is why setImmediate exists |
| close callbacks | 'close' handlers of destroyed handles |
The poll phase is the heart of the loop. When nothing else is scheduled, Node sits in the poll phase inside epoll_wait (Linux) or its equivalent, with the thread sleeping until the kernel reports activity on a socket or the thread pool completes a task. This is why an idle Node server uses almost no CPU. The poll phase also calculates how long it may block: until the nearest timer is due.
Microtasks between callbacks. Since Node 11, the nextTick queue and the promise microtask queue are drained after each callback, not only between phases, which matches browser behaviour.
Predicting the order.
// order.cjs — run with: node order.cjs
const fs = require('node:fs');
console.log('1 sync');
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('2 nextTick'));
Promise.resolve().then(() => console.log('3 promise'));
fs.readFile(__filename, () => {
console.log('readFile callback (poll phase)');
setTimeout(() => console.log('timeout inside I/O'), 0);
setImmediate(() => console.log('immediate inside I/O')); // always first: check comes right after poll
});
console.log('1b sync');
1 sync
1b sync
2 nextTick
3 promise
timeout <- these two may swap (see below)
immediate
readFile callback (poll phase)
immediate inside I/O <- always before the timeout scheduled in the same callback
timeout inside I/O
- Synchronous code runs first, to completion.
- Before the loop starts, the
nextTickqueue, then the promise queue, are drained. - The first loop iteration enters the timers phase. Whether the 0 ms timer (internally 1 ms) has already expired depends on how long start-up took, so
timeoutandimmediatecan appear in either order at the top level. - Inside an I/O callback (poll phase), the next phase is always check, so
setImmediatedeterministically runs before a new timer.
A note on ES modules. In an ES module, top-level code is itself evaluated as part of a promise job, so the promise microtask queue is drained before the nextTick queue gets its turn: in order.mjs, 3 promise prints before 2 nextTick. Code should never depend on this fine-grained ordering; it is shown only to explain surprising logs.
Key Takeaways
- Node's loop runs phases in order: timers, pending callbacks, idle/prepare, poll, check, close callbacks.
- The poll phase handles completed I/O and is where the loop sleeps when there is nothing to do.
setImmediateruns in the check phase, right after poll; timers run in the timers phase.- After every callback, the
nextTickqueue and then the promise microtask queue are drained completely.
Practice
- (Beginner) Run the
order.cjsexample several times and observe whethertimeoutandimmediatechange order. Then explain why the pair inside thereadFilecallback never changes.- (Intermediate) Predict the output of a program in which a
setImmediatecallback schedules aprocess.nextTick, a promise and anothersetImmediate. Run it to check your prediction and explain it phase by phase.- (Interview) "Walk me through what happens in the event loop when an HTTP request arrives at a Node.js server and the handler queries a database." Hint: follow the request from the kernel's socket notification in the poll phase, through the handler, the database driver's socket write, back to the poll phase and the response.
The Thread Pool and Offloaded Work
Node is often called single-threaded, but a running Node process has several threads. Only one runs your JavaScript. Others belong to V8 (garbage collection, compilation) and to libuv's thread pool, which performs operations that cannot be done without blocking at the operating-system level.
Why some operations need threads. Network sockets are well supported by OS readiness mechanisms (epoll, kqueue, IOCP): the kernel can tell libuv "this socket has data". Regular files are different. On most platforms, epoll does not work for regular files, and a read() on a file always blocks until the data comes from the disk. DNS lookups through getaddrinfo are also blocking C library calls. libuv's solution is to run these calls on a pool of worker threads, so the main thread does not block.
What uses the thread pool.
| Uses the thread pool | Does not (handled by the OS asynchronously) |
|---|---|
Asynchronous fs operations (except fs.watch) | TCP and UDP sockets (net, http, dgram) |
dns.lookup() (used by default when connecting to a hostname) | dns.resolve*() (uses c-ares over the network) |
Async crypto: pbkdf2, scrypt, randomBytes, randomFill, key generation | Pipes, TTYs, child process I/O |
Async zlib compression and decompression | Timers |
Native add-ons that use uv_queue_work | Signals |
The default size is 4. With more than four simultaneous thread-pool tasks, the rest queue up. The effect is easy to observe:
// pool.cjs — six expensive hashes at once
const crypto = require('node:crypto');
const start = Date.now();
for (let i = 1; i <= 6; i++) {
crypto.pbkdf2('secret', 'salt', 500_000, 64, 'sha512', () => {
console.log(`hash ${i} done after ${Date.now() - start} ms`);
});
}
$ node pool.cjs $ UV_THREADPOOL_SIZE=6 node pool.cjs
hash 1 done after 410 ms hash 1 done after 430 ms
hash 3 done after 412 ms hash 2 done after 433 ms
hash 2 done after 414 ms hash 4 done after 435 ms
hash 4 done after 418 ms hash 3 done after 436 ms
hash 5 done after 822 ms <- waited hash 5 done after 438 ms
hash 6 done after 825 ms <- waited hash 6 done after 440 ms
(timings vary with the machine and the number of cores)
main thread (JavaScript, event loop)
|
pbkdf2 x6 | completion events
+----------------------+------------------------+
v ^
+--------------+ |
| work queue | tasks 5, 6 wait here |
+------+-------+ |
v |
[ T1 ] [ T2 ] [ T3 ] [ T4 ] libuv thread pool --------+
Tuning. Set UV_THREADPOOL_SIZE (up to 1024) as an environment variable before the process starts; setting it later from JavaScript is unreliable because the pool may already exist. Increasing it helps when many file or crypto operations run concurrently, but more threads than CPU cores does not make CPU-bound work faster, and each thread costs memory.
A hidden bottleneck: DNS. http.get('http://api.example.com') resolves the hostname with dns.lookup(), which uses the pool. A slow DNS server can occupy all four threads, and then even unrelated fs.readFile calls wait. Remedies include caching DNS results, using IP addresses for internal services, or increasing the pool size.
The thread pool is not for your JavaScript. You cannot submit a JavaScript function to libuv's pool. To run your own CPU-heavy JavaScript in parallel, use worker threads (11.5).
Key Takeaways
- Only one thread runs JavaScript; libuv keeps a pool of threads (4 by default) for blocking system work.
- File system operations,
dns.lookup, async crypto and zlib use the pool; network sockets do not. - When more tasks than threads are submitted, they queue;
UV_THREADPOOL_SIZE(set before start-up) changes the size. - Your own JavaScript cannot run on the pool; CPU-heavy JavaScript belongs in worker threads.
Practice
- (Beginner) Run the
pbkdf2example with pool sizes 1, 2, 4 and 8, record the completion times and explain the pattern.- (Intermediate) Start eight
crypto.pbkdf2calls and, at the same time, a singlefs.readFileof a small file. Measure when the file read completes, and explain why.- (Interview) "Our Node service has high latency on file uploads only when many users log in at the same time. What could be happening?" Hint: think about which resource password hashing and file writes might share.
process.nextTick vs setImmediate
Node offers several ways to say "run this later". The two Node-specific ones have confusing names: process.nextTick runs before the next tick of the event loop, and setImmediate runs after the current poll phase, not immediately. Their names are historical accidents that cannot be changed without breaking the ecosystem.
Comparison.
| API | Queue | When it runs | Typical use |
|---|---|---|---|
process.nextTick(fn) | nextTick queue (Node-specific) | After the current operation, before promise microtasks and before the loop continues | Let callers attach listeners before an event is emitted; consistently async callbacks in libraries |
queueMicrotask(fn) / Promise.then | Promise microtask queue | After the nextTick queue is empty, before the loop continues | Standard, portable way to defer a little |
setImmediate(fn) | check phase | In the check phase of the current or next loop iteration | Yield to I/O, split long work into chunks |
setTimeout(fn, 0) | timers phase | In a later timers phase, after at least 1 ms | Delays; portable "run later" in browsers |
setImmediate(() => console.log('4 setImmediate'));
setTimeout(() => console.log('3/4 setTimeout'), 0);
Promise.resolve().then(() => console.log('2 promise'));
process.nextTick(() => console.log('1 nextTick'));
// CommonJS output: 1 nextTick, 2 promise, then timeout and immediate (order varies at top level)
Why process.nextTick exists. A classic case: an object emits an event from its constructor before the caller has had a chance to attach a listener.
const EventEmitter = require('node:events');
class Connection extends EventEmitter {
constructor() {
super();
// Wrong: this.emit('ready') here would fire before anyone could listen.
// Right: defer the emit until the current synchronous code has finished.
process.nextTick(() => this.emit('ready'));
}
}
const conn = new Connection();
conn.on('ready', () => console.log('connected')); // attached in time, so this runs
The same idea applies to callbacks: an API should be always synchronous or always asynchronous, never "sometimes". Calling the callback synchronously when a value is cached and asynchronously otherwise (sometimes called "releasing Zalgo") produces order-dependent bugs.
const cache = new Map();
function getUser(id, cb) {
if (cache.has(id)) {
// Calling cb(null, cache.get(id)) directly would be synchronous on the cached path only
process.nextTick(cb, null, cache.get(id)); // keep the API consistently asynchronous
return;
}
db.findUser(id, (err, user) => {
if (!err) cache.set(id, user);
cb(err, user);
});
}
The danger: starving the loop. The nextTick queue is drained completely before the loop moves on, including ticks added by ticks. Recursive nextTick therefore blocks all I/O forever:
function spin() {
process.nextTick(spin); // never yields: timers, sockets and immediates never run
}
spin();
setTimeout(() => console.log('never printed'), 10);
setImmediate does not have this problem: immediates scheduled during the check phase run in the next iteration, so I/O gets a turn in between. This makes it the right tool for splitting long computations:
// Process a large array in chunks, letting I/O run between chunks
function processAll(items, handle, done) {
let i = 0;
function chunk() {
const end = Math.min(i + 1000, items.length);
for (; i < end; i++) handle(items[i]);
if (i < items.length) setImmediate(chunk); // yield to the event loop, then continue
else done();
}
chunk();
}
Recommendation. Prefer queueMicrotask or promises for "after this code, before anything else", setImmediate for "after pending I/O", and reserve process.nextTick for library code that needs its specific guarantees. The Node documentation itself recommends setImmediate in most cases because it is easier to reason about.
Key Takeaways
process.nextTickruns before promise microtasks and before the loop continues;setImmediateruns in the check phase after poll.nextTicklets callers attach listeners before an event fires and keeps APIs consistently asynchronous.- Recursive
nextTick(or microtasks) starves the event loop; recursivesetImmediatedoes not. - Use
setImmediateto break up long work so I/O callbacks can run in between.
Practice
- (Beginner) Write a script that schedules one callback with each of
process.nextTick,queueMicrotask,setImmediateandsetTimeout(fn, 0)inside anfs.readFilecallback, and explain the output order.- (Intermediate) Write a function that sums the numbers from 1 to 100 million in chunks using
setImmediate, while asetIntervalprints a heartbeat every 100 ms. Show that the heartbeat keeps printing, then switch toprocess.nextTickand observe the difference.- (Interview) "What is the difference between
process.nextTickandsetImmediate, and which would you use to break a CPU-heavy loop into chunks?" Hint: focus on when each queue is drained relative to the poll phase, and what that means for pending I/O.
Callback, Promise and async/await APIs in Node
Node's core APIs were designed in 2009, before promises existed in JavaScript, so they use error-first callbacks. Promises arrived in ES2015 and async/await in ES2017; Node has since added promise-based versions of most core modules. You will meet all three styles in real code and need to convert between them.
Error-first callbacks. The convention: the last argument is a callback whose first parameter is an error (or null), followed by results.
const fs = require('node:fs');
fs.readFile('config.json', 'utf8', (err, text) => {
if (err) { // always check the error first
console.error('cannot read config:', err.code); // e.g. 'ENOENT'
return; // and return, or the code below runs with text undefined
}
const config = JSON.parse(text); // note: a throw here is NOT caught by anything above
console.log(config.port);
});
Nested callbacks for sequential steps quickly become hard to read ("callback hell", Chapter 5.2), and errors must be checked at every level.
Promise-based core APIs. Many core modules have promise variants:
| Callback module | Promise variant |
|---|---|
node:fs | node:fs/promises |
node:timers | node:timers/promises (setTimeout, setInterval as async iterator) |
node:dns | node:dns/promises |
node:stream (pipeline, finished) | node:stream/promises |
node:readline | node:readline/promises |
node:events | events.once() and events.on() return a promise / async iterator |
// ES module with top-level await
import { readFile, writeFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
try {
const config = JSON.parse(await readFile('config.json', 'utf8'));
await sleep(500); // promise-based delay
config.updatedAt = new Date().toISOString();
await writeFile('config.json', JSON.stringify(config, null, 2));
} catch (err) {
console.error('update failed:', err.message); // catches I/O and JSON errors alike
}
Converting callbacks to promises. util.promisify wraps any function that follows the error-first convention:
const { promisify } = require('node:util');
const { execFile } = require('node:child_process');
const execFileAsync = promisify(execFile);
async function gitBranch() {
const { stdout } = await execFileAsync('git', ['branch', '--show-current']);
return stdout.trim();
}
For functions that do not follow the convention, wrap them manually with new Promise((resolve, reject) => ...). The reverse, util.callbackify, turns an async function into a callback-style one for legacy interfaces.
Running operations concurrently. await in a loop runs operations one after another. When they are independent, start them together:
import { readFile } from 'node:fs/promises';
const files = ['a.json', 'b.json', 'c.json'];
// Sequential: total time = sum of all reads
for (const f of files) await readFile(f, 'utf8');
// Concurrent: total time = slowest read
const contents = await Promise.all(files.map(f => readFile(f, 'utf8')));
// Concurrent, tolerating individual failures
const results = await Promise.allSettled(files.map(f => readFile(f, 'utf8')));
for (const r of results) {
if (r.status === 'rejected') console.warn('failed:', r.reason.code);
}
Cancellation. Many Node APIs accept an AbortSignal, the same mechanism fetch uses:
import { readFile } from 'node:fs/promises';
const signal = AbortSignal.timeout(2000); // abort after 2 seconds
try {
const data = await readFile('huge.bin', { signal });
} catch (err) {
if (err.name === 'AbortError') console.log('read cancelled');
else throw err;
}
Unhandled rejections crash the process. Since Node 15, a promise that rejects with no handler terminates the process (--unhandled-rejections=throw is the default). This is deliberate: silently ignored errors leave applications in unknown states. Always await or .catch() promises, including "fire and forget" ones.
// Fire and forget, but handle the error
sendAnalyticsEvent(evt).catch(err => logger.warn({ err }, 'analytics failed'));
Key Takeaways
- Node's classic APIs use error-first callbacks:
(err, result) => {}; always check and return onerr. - Promise variants (
fs/promises,timers/promises,stream/promises, ...) work naturally withasync/awaitandtry/catch. util.promisifyconverts error-first functions;Promise.all/allSettledrun independent operations concurrently.- Many APIs accept an
AbortSignalfor cancellation; unhandled promise rejections terminate the process.
Practice
- (Beginner) Rewrite a callback-based script that reads two files, concatenates them and writes the result, using
fs/promisesandasync/await.- (Intermediate) Implement a function
mapLimit(items, limit, fn)that runs an async function over an array with at mostlimitoperations in flight at once, and use it to read 100 files with a limit of 10.- (Interview) "What happens in Node.js if a promise rejects and nobody handles it, and why did Node change the behaviour?" Hint: compare the old warning-only behaviour with the current default, and think about what state a server is in after an error nobody noticed.
Concurrency vs Parallelism in Node
"Node can handle 10,000 requests at once" and "Node is single-threaded" are both true, because they describe different things. Concurrency is about dealing with many tasks at once by interleaving them; parallelism is about doing many tasks at the same instant on multiple CPU cores.
An analogy: a single chef who starts the pasta water, chops vegetables while it heats, and stirs the sauce in between is working concurrently. Two chefs each cooking a dish at the same time are working in parallel.
Concurrency (one JS thread, interleaved)
thread [A1][B1][C1]......[A2]..[C2][B2]
| | | ^ ^ ^
| | +-- I/O ---|-----+ | I/O waits overlap,
| +------ I/O ---|---------+ CPU work never does
+---------- I/O ---+
Parallelism (several threads or processes, simultaneous)
core 1 [A1][A2][A3][A4]
core 2 [B1][B2][B3][B4]
core 3 [C1][C2][C3][C4]
What Node gives you.
| Kind of work | Single Node process | How to get more |
|---|---|---|
| Waiting on network and disk | Highly concurrent: thousands of in-flight operations | Usually not needed |
| Built-in blocking system work (fs, crypto, zlib) | Parallel on the libuv thread pool | Increase UV_THREADPOOL_SIZE |
| Your own CPU-heavy JavaScript | Sequential on one core; blocks everything else | Worker threads (11.5), child processes |
| Serving more requests than one core can compute | Limited to one core | Cluster module, multiple containers behind a load balancer (11.5) |
I/O concurrency in practice. Firing many requests concurrently costs almost nothing on the JavaScript side:
// 50 HTTP calls in flight at once, on a single thread
const ids = Array.from({ length: 50 }, (_, i) => i + 1);
console.time('all');
const users = await Promise.all(
ids.map(id => fetch(`https://jsonplaceholder.typicode.com/users/${(id % 10) + 1}`).then(r => r.json()))
);
console.timeEnd('all'); // roughly the time of the slowest request, not 50 times one request
CPU work does not interleave. Promises do not make computation parallel. Wrapping a loop in async does not move it off the main thread:
async function heavy() { // async does NOT mean "runs in the background"
let x = 0;
for (let i = 0; i < 2e9; i++) x += i; // still runs on the main thread, blocking it
return x;
}
setTimeout(() => console.log('timer'), 10);
await Promise.all([heavy(), heavy()]); // runs one after the other; the timer waits for both
Limit concurrency when it hurts others. Unlimited concurrency can overwhelm a downstream service, exhaust database connection pools or hit API rate limits. Use a fixed concurrency limit (a pool or a library such as p-limit) and connection pools in database drivers.
Shared state is still simple. Because JavaScript code itself never runs in parallel within a thread, there are no data races on ordinary variables: a function runs to completion before another callback starts. But there are logical races across await points:
// Race across an await: two concurrent requests can both read 100 and both write 90
async function withdraw(accountId, amount) {
const balance = await db.getBalance(accountId); // other requests can run while we wait
if (balance < amount) throw new Error('insufficient funds');
await db.setBalance(accountId, balance - amount); // may overwrite a concurrent update
}
// Fix: let the database do it atomically, e.g.
// UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1
Key Takeaways
- Concurrency is interleaving many tasks; parallelism is running them at the same instant on several cores.
- One Node process is highly concurrent for I/O, and uses the thread pool in parallel for some built-in work.
- Your JavaScript runs on one core:
asyncdoes not make computation parallel; use workers or more processes. - No low-level data races, but logical races across
awaitpoints still require atomic operations or locks.
Practice
- (Beginner) Time 20 sequential
fetchcalls against 20 concurrent calls withPromise.all, and explain the difference.- (Intermediate) Simulate the
withdrawrace with an in-memory "database" whose methodsawaita random delay, show that two concurrent withdrawals can overdraw the account, and fix it with a per-account promise-based lock.- (Interview) "Is Node.js concurrent, parallel, both or neither?" Hint: answer separately for JavaScript execution, for I/O, for the libuv thread pool and for a cluster of processes.
Diagnosing Event Loop Blocking
When the event loop is blocked, every request in the process slows down at once, including health checks, which can cause orchestrators to restart healthy-but-busy instances. The symptoms (latency spikes, timeouts, a CPU at 100% on one core) are easy to see; finding the responsible code needs measurement.
Common causes.
| Cause | Example | Fix |
|---|---|---|
| Synchronous I/O in handlers | fs.readFileSync, execSync, crypto.pbkdf2Sync | Use asynchronous variants |
| Large JSON | JSON.parse / JSON.stringify on tens of megabytes | Paginate, stream (NDJSON), or parse in a worker |
| Catastrophic regex backtracking (ReDoS) | /^(a+)+$/ against 'aaaaaaaaaaaaaaaaaaaaaaaaaaaa!' | Safer patterns, input length limits, linear-time regex engines |
| Heavy computation | Image resizing, PDF generation, sorting huge arrays | Worker threads, native libraries, a separate service |
| Unbounded loops over user input | Processing an uploaded 1-million-row CSV in one go | Streams and chunking |
| Excessive garbage collection | Allocating huge temporary arrays per request | Reduce allocations, stream data |
Measuring loop delay. The idea: schedule a timer and measure how late it fires. Node provides precise tools in perf_hooks:
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 20 }); // sample every 20 ms
histogram.enable();
let lastElu = performance.eventLoopUtilization();
setInterval(() => {
const elu = performance.eventLoopUtilization(lastElu); // delta since last sample
lastElu = performance.eventLoopUtilization();
console.log({
p50ms: (histogram.percentile(50) / 1e6).toFixed(1), // values are in nanoseconds
p99ms: (histogram.percentile(99) / 1e6).toFixed(1),
maxms: (histogram.max / 1e6).toFixed(1),
utilization: elu.utilization.toFixed(2), // 0 = idle, 1 = always busy
});
histogram.reset();
}, 5000).unref();
Event loop utilization (ELU) is the fraction of time the loop spends running callbacks rather than idling in the poll phase. It is a better saturation signal than CPU usage, which also counts the thread pool and GC. An ELU close to 1 means the process cannot accept more work, and is a good autoscaling metric.
Finding the culprit: profiling.
# CPU profile written to a .cpuprofile file when the process exits
node --cpu-prof server.js
# open the file in Chrome DevTools (Performance panel) or VS Code
# Interactive: attach Chrome DevTools, record a profile while sending load
node --inspect server.js # then open chrome://inspect
# Generate load while profiling
npx autocannon -c 50 -d 20 http://localhost:3000/report
In the resulting flame graph, the x-axis is time (or share of samples) and the y-axis is the call stack. A wide block at the top of a stack is a function spending a long time on the CPU: that is where to look.
Flame graph (simplified): one wide frame dominates
[ processRequest ]
[ buildReport ][ send ]
[ JSON.stringify (huge object) ][ sort ]
^ 70% of samples: the event loop was blocked here
Other useful tools.
node --trace-sync-io app.jsprints a warning with a stack trace whenever synchronous I/O happens after the first tick of the event loop.- Clinic.js (
clinic doctor,clinic flame,clinic bubbleprof) diagnoses common performance problems and visualises them. - Application performance monitoring (APM) tools and OpenTelemetry report event-loop lag and slow transactions in production.
- Log a warning when the measured delay exceeds a threshold, with the current request's route.
Key Takeaways
- A blocked loop delays every request in the process; typical causes are sync I/O, big JSON, ReDoS and heavy computation.
- Measure with
monitorEventLoopDelay(lag) andeventLoopUtilization(saturation). - Use CPU profiles and flame graphs (
--cpu-prof,--inspect, Clinic.js) to find the responsible function. - Fix by going asynchronous, streaming, chunking, limiting input size or moving work to worker threads.
Practice
- (Beginner) Add the event-loop monitoring snippet to a small server, then add a route that runs a 500 ms busy loop. Call it a few times and observe the p99 and max delay.
- (Intermediate) Profile a server with
--cpu-profwhile load-testing an endpoint that sorts a large random array per request. Identify the hot function in the flame graph and fix it, for example by caching or moving the work to a worker.- (Interview) "Your Node API's p99 latency jumped from 50 ms to 2 s after a release, but average CPU is only 30%. How do you investigate?" Hint: consider why average CPU can be misleading for a single-threaded process, which metrics show loop blocking, and how you would locate the code responsible.
<a id="114-event-driven-programming"></a>
11.4 Event-Driven Programming
This subchapter covers the second pillar of Node's design: objects that announce what happens to them through events. It starts with the observer pattern and the EventEmitter API, then moves to streams, the event-based abstraction for data that arrives over time, including piping, backpressure and the Buffer type for binary data.
The Observer Pattern in Node
Many things in a server happen at unpredictable moments: a client connects, a chunk of a file arrives, a child process exits, a timer fires. Code that cares about these moments could poll ("has anything happened yet?"), but polling wastes CPU and adds latency. The alternative is to let the source notify interested parties when something happens. This is the observer pattern (also called publish/subscribe when a broker sits in between).
A newsletter is the classic analogy: readers subscribe once; the publisher sends each issue to all subscribers; readers can unsubscribe at any time. The publisher does not need to know who the readers are or what they do with the issue.
subject (emitter)
+---------------------+
| listeners: |
| 'data' -> [f, g] | emit('data', chunk)
on('data', f) --> | ----------------------> f(chunk)
on('data', g) --> | ----------------------> g(chunk)
on('end', h) --> 'end' -> [h] | emit('end')
+---------------------+ ----------------------> h()
Roles.
| Role | Responsibility | In Node |
|---|---|---|
| Subject (emitter) | Keeps a list of observers per event and notifies them | EventEmitter and its subclasses |
| Observer (listener) | A function called when the event occurs | The callback passed to .on() |
| Event | A name plus data describing what happened | 'data' with a Buffer, 'error' with an Error |
Why it fits Node. The pattern decouples the code that detects something from the code that reacts to it. The http server detects requests; your application decides what to do. A stream detects incoming data; several consumers (a hash calculator, a progress bar, a file writer) can react independently.
Events throughout Node's core. Most core objects that represent something long-lived are event emitters:
| Object | Notable events |
|---|---|
http.Server | 'request', 'connection', 'close', 'error' |
net.Socket | 'connect', 'data', 'end', 'close', 'error', 'timeout' |
| Readable stream | 'data', 'end', 'readable', 'error', 'close' |
ChildProcess | 'spawn', 'exit', 'close', 'error' |
process | 'exit', 'beforeExit', 'uncaughtException', 'SIGINT', 'warning' |
fs.FSWatcher | 'change' (with an event type of 'rename' or 'change'), 'error', 'close' |
Events vs callbacks vs promises.
| Mechanism | Fires | Best for |
|---|---|---|
| Callback | Once | A single result of an operation (classic Node APIs) |
| Promise | Once | A single result, composable with async/await |
| Event emitter | Zero, one or many times, for several listeners | Ongoing activity: connections, data chunks, status changes |
A useful rule: if something happens once, return a promise; if it can happen repeatedly or several parties care, emit events.
Key Takeaways
- The observer pattern lets a subject notify any number of listeners when something happens, without polling or knowing who they are.
- In Node,
EventEmitterimplements the pattern, and servers, sockets, streams, processes and watchers all build on it. - Events decouple detection from reaction and support multiple independent consumers.
- Use promises for one-off results and events for repeated or multi-consumer notifications.
Practice
- (Beginner) List four Node core objects you have used and, for each, two events it emits and what they mean.
- (Intermediate) Design (on paper) the events a
FileUploaderobject should emit, including progress and failure, and justify whether each one should instead be a promise.- (Interview) "When would you expose an API as an event emitter rather than returning a promise?" Hint: think about how many times the outcome occurs, how many consumers there are and whether consumers need intermediate progress.
The EventEmitter API
The EventEmitter class from node:events is a small, general-purpose implementation of the observer pattern. You can use it directly, but more commonly you extend it so your own classes can emit events.
Basic usage.
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.on('greet', (name) => { // register a listener
console.log(`Hello, ${name}!`);
});
emitter.emit('greet', 'Ada'); // call all 'greet' listeners with 'Ada'
// Hello, Ada!
Extending EventEmitter. Domain objects emit events that describe their own life cycle:
import { EventEmitter } from 'node:events';
class OrderQueue extends EventEmitter {
#orders = [];
add(order) {
this.#orders.push(order);
this.emit('added', order, this.#orders.length); // any number of arguments
if (this.#orders.length >= 100) this.emit('full');
}
next() {
const order = this.#orders.shift();
if (this.#orders.length === 0) this.emit('drained');
return order;
}
}
const queue = new OrderQueue();
queue.on('added', (order, size) => console.log(`order ${order.id} queued (${size})`));
queue.on('drained', () => console.log('queue is empty'));
queue.add({ id: 1 }); // order 1 queued (1)
queue.next(); // queue is empty
Main methods.
| Method | Purpose |
|---|---|
on(event, fn) / addListener | Add a listener (appended to the list) |
prependListener(event, fn) | Add a listener at the beginning of the list |
once(event, fn) | Add a listener that is removed after its first call |
off(event, fn) / removeListener | Remove one specific listener |
removeAllListeners([event]) | Remove all listeners (for one event, or every event) |
emit(event, ...args) | Call listeners synchronously; returns true if there were any |
listenerCount(event) / listeners(event) | Inspect registered listeners |
eventNames() | List events that currently have listeners |
setMaxListeners(n) | Change the leak-warning threshold (default 10 per event) |
Promise helpers. The events module also bridges events and async/await:
import { once, on } from 'node:events';
import { createServer } from 'node:http';
const server = createServer((req, res) => res.end('ok'));
server.listen(3000);
await once(server, 'listening'); // wait for a single event as a promise
console.log('server ready');
// Iterate over a stream of events; the loop runs until the signal aborts
const ac = new AbortController();
setTimeout(() => ac.abort(), 10_000);
try {
for await (const [req] of on(server, 'request', { signal: ac.signal })) {
console.log('request for', req.url);
}
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
once() rejects if an 'error' event is emitted first, which makes it safe for waiting on things that may fail.
EventEmitter vs EventTarget. Node also implements the web-standard EventTarget (used by AbortSignal, MessagePort and BroadcastChannel). The two are similar but not interchangeable:
EventEmitter (Node) | EventTarget (web standard) | |
|---|---|---|
| Register | on(name, fn) | addEventListener(name, fn) |
| Fire | emit(name, ...args) with any arguments | dispatchEvent(new Event(name)) with a single event object |
'error' | Special: throws if unhandled | Not special |
| Available in browsers | No (only via polyfills) | Yes |
Key Takeaways
- Create an emitter directly or, more often, extend
EventEmitterin your own classes. on,once,offandemitcover most needs;emitpasses any number of arguments to listeners.events.once()turns a single event into a promise, andevents.on()into an async iterator.EventTargetis the web-standard alternative used by some Node APIs; its API differs fromEventEmitter.
Practice
- (Beginner) Create a
Timerclass extendingEventEmitterthat emits'tick'with the elapsed seconds every second and'done'after a given duration.- (Intermediate) Write an async function
waitForReady(emitter, timeoutMs)that resolves on a'ready'event, rejects on'error', and rejects with a timeout error otherwise, usingevents.onceand anAbortSignal.- (Interview) "How would you implement a minimal
EventEmitteryourself?" Hint: think about a map from event names to arrays of functions, whatoncemust do, and what happens if a listener removes itself duringemit.
Emitting, Listening and Removing Listeners
Using emitters correctly requires knowing a few precise rules: listeners run synchronously and in order, this has a specific value, and listeners that are never removed keep objects alive.
Listeners run synchronously. emit() calls every listener before it returns. Emitting does not schedule anything on the event loop:
import { EventEmitter } from 'node:events';
const e = new EventEmitter();
e.on('ping', () => console.log('listener 1'));
e.on('ping', () => console.log('listener 2'));
e.prependListener('ping', () => console.log('listener 0')); // goes to the front
console.log('before');
e.emit('ping');
console.log('after');
// before, listener 0, listener 1, listener 2, after
Consequences:
- A slow listener delays the emitter and every listener after it. Move slow work into an asynchronous operation.
- An exception thrown in a listener propagates out of
emit()to the code that emitted the event, and the remaining listeners do not run. - To make a listener asynchronous, defer its work explicitly (
setImmediate,queueMicrotask, or an async function).
Async listeners and errors. An async listener returns a promise that emit() ignores. If it rejects, the rejection is unhandled and, by default, crashes the process. Either catch inside the listener or enable captureRejections, which routes rejections to the 'error' event:
const e = new EventEmitter({ captureRejections: true });
e.on('job', async (job) => {
await processJob(job); // a rejection here becomes an 'error' event
});
e.on('error', (err) => console.error('job failed:', err.message));
this inside listeners. With a regular function, this is the emitter; with an arrow function, it is the enclosing scope's this, as usual (Chapter 3.3):
e.on('ping', function () { console.log(this === e); }); // true
e.on('ping', () => { console.log(this === e); }); // false
Removing listeners. off() needs a reference to the same function that was registered, so anonymous inline functions cannot be removed individually:
function onData(chunk) { console.log('got', chunk.length, 'bytes'); }
socket.on('data', onData);
// ... later
socket.off('data', onData); // works: same function reference
socket.on('data', (c) => console.log(c)); // cannot be removed with off() later
Listeners registered with once() remove themselves after the first call, and the promise helpers events.once() and events.on() accept a { signal } option, so aborting one AbortController removes all the listeners they created:
socket.once('connect', () => console.log('connected')); // removed automatically after it fires
Why removal matters. A listener keeps everything it references (its closure) alive for as long as the emitter is alive. Registering listeners on a long-lived emitter (such as process, a shared database client or a global event bus) inside a function that runs per request leaks memory:
// Leak: every request adds a listener to a long-lived object and never removes it
app.get('/stream', (req, res) => {
const onPrice = (p) => res.write(`data: ${p}\n\n`);
priceFeed.on('price', onPrice);
req.on('close', () => priceFeed.off('price', onPrice)); // fix: remove on disconnect
});
Order of events for a value set before listening. Emitters do not store past events. If you subscribe after the event fired, you miss it. That is why APIs often expose a state property as well (for example server.listening, stream.readableEnded), or why libraries defer their first emit with process.nextTick.
Key Takeaways
emit()calls listeners synchronously, in registration order (prependListenerputs one first).- A throwing listener stops the emit and propagates the error to the emitter; async listeners need their own error handling or
captureRejections. - Remove listeners with
off()using the original function reference,once()for one-shot handlers, or anAbortSignal. - Listeners on long-lived emitters that are never removed are a common cause of memory leaks.
Practice
- (Beginner) Register three listeners on an event, make the second one throw, and observe which listeners run and where the error appears.
- (Intermediate) Write a helper
onceWithin(emitter, event, ms)that removes its listener after the event or after a timeout, whichever comes first, and verify withlistenerCountthat nothing is left behind.- (Interview) "Are EventEmitter listeners called asynchronously?" Hint: describe what
emitdoes before it returns, and the consequences for errors and for slow listeners.
Error Events and Memory Leak Warnings
Two behaviours of EventEmitter surprise newcomers: an 'error' event with no listener crashes the process, and adding many listeners to one event prints a warning about a possible memory leak. Both are safety features.
The special 'error' event. Asynchronous operations cannot throw to their caller, because the caller's stack frame no longer exists when the error happens. Emitters report failures by emitting 'error' instead. If no listener is registered for 'error', emit('error', err) throws the error, which usually means an uncaught exception and a crash:
import { createReadStream } from 'node:fs';
const stream = createReadStream('missing.txt');
// No 'error' listener: the process crashes with
// Error: ENOENT: no such file or directory, open 'missing.txt'
// Emitted 'error' event on ReadStream instance at: ...
const safe = createReadStream('missing.txt');
safe.on('error', (err) => {
console.error('could not read file:', err.code); // handled: the process keeps running
});
The design is deliberate: an error with no handler is a bug, and continuing silently could corrupt state. Rule: every emitter that can fail (streams, sockets, servers, child processes, database clients) must have an 'error' listener, or must be used through an API that handles errors for you (such as stream.pipeline, events.once or promise-based wrappers).
try/catch does not help. The error is emitted later, from the event loop:
try {
createReadStream('missing.txt'); // returns immediately; the open happens later
} catch (err) {
// never reached: the error is emitted after this block has finished
}
Observing errors without handling them. events.errorMonitor registers a listener that runs before regular 'error' listeners but does not count as handling, which is useful for logging or metrics:
import { EventEmitter, errorMonitor } from 'node:events';
const e = new EventEmitter();
e.on(errorMonitor, (err) => metrics.increment('emitter.errors'));
e.on('error', (err) => console.error(err.message)); // still required to prevent a crash
The memory-leak warning. By default, adding more than 10 listeners for the same event on one emitter prints:
(node:12345) MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 data listeners added to [Socket]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
The listener is still added; this is only a warning. It exists because the most common cause of more than 10 listeners is a bug: a listener registered inside a loop or a per-request function without ever being removed.
What to do with the warning.
- Find where listeners are added:
node --trace-warnings app.jsprints the stack trace of the warning. - If listeners are accumulating unintentionally, fix the leak (remove them, use
once, register once outside the loop). - If many listeners are legitimate (a pub/sub hub with many subscribers), raise the limit explicitly for that emitter:
emitter.setMaxListeners(100), or0for unlimited. Avoid raisingEventEmitter.defaultMaxListenersglobally, which hides real leaks elsewhere.
// Bug: a new 'exit' listener for every call to connect()
function connect() {
const conn = openConnection();
process.on('exit', () => conn.close()); // after 11 calls: MaxListenersExceededWarning
return conn;
}
// Fix: track resources in one place and register the listener once
const open = new Set();
process.once('exit', () => { for (const c of open) c.close(); });
function connectFixed() {
const conn = openConnection();
open.add(conn);
conn.on('close', () => open.delete(conn));
return conn;
}
Key Takeaways
- Emitting
'error'with no listener throws, usually crashing the process; every emitter that can fail needs an'error'handler. try/catcharound the call that creates an emitter cannot catch errors emitted later.errorMonitorobserves errors without counting as a handler.- More than 10 listeners per event triggers a leak warning; investigate with
--trace-warningsbefore raising the limit for that emitter.
Practice
- (Beginner) Create a read stream for a non-existent file with and without an
'error'listener, and compare the outcomes.- (Intermediate) Reproduce
MaxListenersExceededWarningby adding a listener inside a loop, use--trace-warningsto locate it, and fix the code so the warning disappears without changing the limit.- (Interview) "Why does Node crash when an EventEmitter emits 'error' with no listener, instead of ignoring it?" Hint: compare an ignored asynchronous failure with an uncaught synchronous exception, and think about what state the application is left in.
Readable, Writable, Duplex and Transform Streams
Reading a 4 GB log file with readFile loads all 4 GB into memory before you can process the first line. Many sources (files, network sockets, HTTP bodies, compressed archives, database cursors) produce data over time and can be far larger than memory. Streams process data piece by piece as it arrives, with constant memory use, and can be chained like Unix pipes.
An analogy: readFile is filling a bathtub with buckets and then carrying the whole tub; a stream is a garden hose that delivers water continuously to wherever it is needed.
The four types.
| Type | Direction | Examples |
|---|---|---|
| Readable | Source: you read from it | fs.createReadStream, HTTP request on a server (req), process.stdin |
| Writable | Destination: you write to it | fs.createWriteStream, HTTP response (res), process.stdout |
| Duplex | Both, independent sides | TCP socket (net.Socket), TLS socket |
| Transform | Duplex where output is computed from input | zlib.createGzip(), crypto.createHash (as a stream), a CSV parser |
Readable ──> Transform ──> Transform ──> Writable
file.log gunzip parse lines database / socket / file
Duplex (socket): your writes ──> network
network ──> your reads (two independent channels)
All streams are event emitters, and data flows in chunks: Buffers by default, strings if an encoding is set, or arbitrary JavaScript values in object mode.
Consuming a Readable. Three styles:
import { createReadStream } from 'node:fs';
// 1. Async iteration (recommended): readable, handles backpressure, errors become exceptions
let bytes = 0;
for await (const chunk of createReadStream('big.log')) {
bytes += chunk.length; // chunk is a Buffer (64 KiB by default for files)
}
console.log('total', bytes);
// 2. Events ('flowing' mode)
const rs = createReadStream('big.log', { encoding: 'utf8' });
rs.on('data', (chunk) => { /* chunk is a string because an encoding was set */ });
rs.on('end', () => console.log('no more data'));
rs.on('error', (err) => console.error(err));
// 3. Piping into a Writable (next topic)
Writing to a Writable.
import { createWriteStream } from 'node:fs';
const out = createWriteStream('report.csv');
out.write('id,total\n'); // returns false when the internal buffer is full
out.write('1,42.00\n');
out.end('2,13.50\n'); // last chunk, then close
out.on('finish', () => console.log('all data flushed'));
out.on('error', (err) => console.error('write failed', err));
Implementing your own streams. Subclass or use the simplified constructors. A Transform that upper-cases text:
import { Transform } from 'node:stream';
const upperCase = new Transform({
transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase()); // push transformed data and signal "ready for more"
},
});
process.stdin.pipe(upperCase).pipe(process.stdout); // try: echo hello | node upper.js
A Readable in object mode, producing records from an async generator:
import { Readable } from 'node:stream';
async function* fetchPages() {
for (let page = 1; page <= 3; page++) {
const res = await fetch(`https://api.example.com/items?page=${page}`);
yield* await res.json(); // yield each item individually
}
}
const items = Readable.from(fetchPages()); // object-mode Readable from any (async) iterable
for await (const item of items) console.log(item.id);
Line-by-line reading. A common real task, handled by readline:
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const rl = createInterface({ input: createReadStream('access.log'), crlfDelay: Infinity });
let errors = 0;
for await (const line of rl) {
if (line.includes(' 500 ')) errors++; // constant memory, even for huge files
}
console.log('5xx responses:', errors);
Web Streams. Node also implements the web-standard ReadableStream, WritableStream and TransformStream (used by fetch bodies). Convert between the two families with Readable.fromWeb(), Readable.toWeb() and their Writable equivalents.
Key Takeaways
- Streams process data incrementally with bounded memory and can be chained.
- Readable is a source, Writable a destination, Duplex both, Transform a Duplex whose output derives from its input.
- Prefer
for await...ofto consume Readables;write()/end()and the'finish'event for Writables. - Build custom streams with
Transform,Readable.from(iterable)and object mode; convert to Web Streams when interoperating withfetch.
Practice
- (Beginner) Count the lines of a large file using
readlineand compare the memory usage (process.memoryUsage().rss) with areadFileplussplit('\n')approach.- (Intermediate) Write a Transform stream that converts CSV lines into JSON objects (object mode) and a Writable that collects them into batches of 100 before "inserting" them (log the batch size).
- (Interview) "When would you use a stream instead of reading the whole file or response into memory?" Hint: consider data size relative to memory, time to first byte for the client and the number of concurrent requests.
Piping and Backpressure
When a fast producer feeds a slow consumer (reading from an SSD at 2 GB/s and uploading over a 10 MB/s network), the data has to go somewhere while it waits. Without a feedback mechanism it accumulates in memory until the process runs out. Backpressure is the mechanism by which a consumer tells the producer to slow down.
How backpressure works. Every stream has an internal buffer whose size limit is highWaterMark (by default 64 KiB for byte streams in recent Node versions, 16 objects in object mode).
Readable Writable
[ producer ] -- chunk --> [ buffer ............ | highWaterMark ] --> slow destination
^
write() returns false ---+ when the buffer is above highWaterMark
producer pauses
'drain' event <------ when the buffer has emptied
producer resumes
Doing it by hand. write() returns false when the buffer is full; well-behaved producers stop writing and wait for 'drain':
import { createWriteStream } from 'node:fs';
import { once } from 'node:events';
const out = createWriteStream('numbers.txt');
for (let i = 0; i < 10_000_000; i++) {
if (!out.write(`${i}\n`)) { // buffer full: respect backpressure
await once(out, 'drain'); // pause until the destination catches up
}
}
out.end();
// Ignoring the return value would still work, but would buffer hundreds of MB in memory
pipe(). readable.pipe(writable) implements this pause/resume logic automatically, and returns the destination so calls can be chained. But it has a serious flaw: it does not forward errors or clean up. If the destination fails, the source keeps its file descriptor open; if the source fails, the destination is not closed.
pipeline(): the correct way. stream.pipeline (or its promise version) connects any number of streams, handles backpressure, propagates errors from any stage and destroys all streams on failure:
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
try {
await pipeline(
createReadStream('access.log'), // 1. read
createGzip(), // 2. compress
createWriteStream('access.log.gz'), // 3. write
);
console.log('compressed');
} catch (err) {
console.error('pipeline failed:', err.message); // any stage's error ends up here; all streams cleaned up
}
Pipelines can include async generator functions as transform stages, which is often simpler than writing a Transform class:
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createInterface } from 'node:readline';
await pipeline(
createReadStream('access.log'),
async function* (source) { // source: chunks from the previous stage
const lines = createInterface({ input: source, crlfDelay: Infinity });
for await (const line of lines) {
if (line.includes(' 500 ')) yield line + '\n'; // only error lines continue down the pipeline
}
},
createWriteStream('errors.log'),
);
Streaming an HTTP response. res is a Writable, so a large file can be served with bounded memory, and a client on a slow connection automatically slows down the disk reads:
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
createServer(async (req, res) => {
res.setHeader('Content-Type', 'video/mp4');
try {
await pipeline(createReadStream('movie.mp4'), res);
} catch (err) {
// client disconnected or file error: streams are already destroyed
console.warn('stream aborted:', err.code);
}
}).listen(3000);
Key Takeaways
- Backpressure stops a fast producer from overwhelming a slow consumer:
write()returnsfalse, and'drain'signals it can resume. highWaterMarksets the buffer threshold for each stream.pipe()handles backpressure but not errors or clean-up; usestream/promisespipeline()instead.pipeline()accepts async generator functions as transform stages and works with HTTP requests and responses.
Practice
- (Beginner) Use
pipelineto copy a large file while compressing it with gzip, then decompress it with another pipeline and compare checksums.- (Intermediate) Write 10 million lines to a file once ignoring
write()'s return value and once respecting backpressure, and compare peak memory (process.memoryUsage().rsssampled withsetInterval).- (Interview) "Why is
readable.pipe(writable)considered unsafe for production code, and what do you use instead?" Hint: think about what happens to file descriptors and sockets when one side errors.
Buffers and Binary Data Handling
JavaScript was designed for text in web pages; for years it had no way to represent raw bytes. A server needs bytes constantly: file contents, TCP packets, images, hashes, encrypted data. Node introduced the Buffer class for this purpose, and when the language later gained Uint8Array, Buffer was rebuilt as a subclass of it.
What a Buffer is. A fixed-length sequence of bytes (integers 0-255), stored outside V8's regular heap. Every chunk read from a file or socket is a Buffer unless you ask for a string.
Creating Buffers.
const a = Buffer.from('Grüße', 'utf8'); // from a string with an encoding
console.log(a); // <Buffer 47 72 c3 bc c3 9f 65>
console.log('Grüße'.length, a.length); // 5 characters, 7 bytes
const b = Buffer.from([0x48, 0x69]); // from byte values
console.log(b.toString()); // 'Hi'
const c = Buffer.alloc(8); // 8 zero-filled bytes
const d = Buffer.allocUnsafe(8); // faster, but may contain old memory: fill before use!
Encodings. Converting between strings and bytes always involves an encoding:
| Encoding | Use |
|---|---|
utf8 (default) | Text |
hex | Human-readable bytes: hashes, debugging |
base64 / base64url | Binary data inside text formats: JSON, HTTP headers, data URLs, JWTs |
latin1, ascii | Legacy single-byte text |
utf16le | Windows APIs, some file formats |
const token = Buffer.from('user:secret').toString('base64'); // 'dXNlcjpzZWNyZXQ='
Buffer.from(token, 'base64').toString('utf8'); // 'user:secret'
import { createHash } from 'node:crypto';
createHash('sha256').update('hello').digest('hex'); // '2cf24dba5fb0a30e...'
Reading and writing binary structures. Network protocols and file formats store integers at fixed offsets and in a defined byte order (endianness):
// Read the width and height from a PNG file header
import { open } from 'node:fs/promises';
const file = await open('image.png');
const header = Buffer.alloc(24);
await file.read(header, 0, 24, 0); // read 24 bytes from position 0
await file.close();
const signature = header.subarray(0, 8); // PNG magic number
console.log(signature.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))); // true
const width = header.readUInt32BE(16); // big-endian 32-bit integer at offset 16
const height = header.readUInt32BE(20);
console.log(`${width}x${height}`);
Views share memory. subarray() returns a view over the same memory, not a copy, so writing to the view changes the original. Use Buffer.from(buf) or Uint8Array.prototype.slice semantics explicitly when you need an independent copy (note that Buffer.prototype.slice is deprecated and behaves like subarray).
const buf = Buffer.from('hello');
const view = buf.subarray(0, 2);
view[0] = 0x4a; // 'J'
console.log(buf.toString()); // 'Jello': the original changed too
Text across chunk boundaries. A multi-byte UTF-8 character can be split between two chunks. Calling chunk.toString() on each chunk then produces garbage (�) at the boundary. Set an encoding on the stream (setEncoding('utf8')), use TextDecoder with { stream: true }, or string_decoder:
const euro = Buffer.from('€'); // 3 bytes: e2 82 ac
const part1 = euro.subarray(0, 2), part2 = euro.subarray(2);
console.log(part1.toString() + part2.toString()); // '��' style garbage, not '€'
const decoder = new TextDecoder('utf-8');
console.log(decoder.decode(part1, { stream: true }) + decoder.decode(part2)); // '€'
Concatenating chunks. Collect chunks in an array and join them once, instead of repeated string concatenation:
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString('utf8');
Security notes. Compare secrets (tokens, HMACs) with crypto.timingSafeEqual(a, b), not === or buf.equals, to avoid timing attacks. Never use allocUnsafe for data sent to clients without overwriting it entirely, because it may contain fragments of earlier memory.
Buffer vs standard types. For code shared with browsers, prefer Uint8Array, TextEncoder/TextDecoder and ArrayBuffer; every Buffer is a Uint8Array, so most Node APIs accept both.
Key Takeaways
Bufferis Node's fixed-length byte array, a subclass ofUint8Array; streams deliver Buffers by default.- Strings and bytes are converted through an encoding:
utf8,hex,base64,base64url, ... readUInt32BEand similar methods parse binary formats;subarraycreates views that share memory.- Decode text across chunks with
setEncodingor a streamingTextDecoder; useBuffer.concatfor collected chunks andtimingSafeEqualfor secrets.
Practice
- (Beginner) Convert the string
"Node.js €"to a Buffer and print its length, hex representation and base64 representation. Explain why the length differs from the string length.- (Intermediate) Write a function that reads the first bytes of a file and identifies whether it is a PNG, JPEG, GIF or PDF from its magic number.
- (Interview) "Why can calling
chunk.toString()on each chunk of a UTF-8 stream corrupt text, and how do you avoid it?" Hint: think about characters that need more than one byte and where chunk boundaries fall.
<a id="115-interface-with-the-operating-system"></a>
11.5 Interface with the Operating System
This subchapter covers the APIs that make Node.js a general-purpose systems runtime rather than only a web server: the file system, paths, the process object, signals, standard input and output, child processes, the os and util modules, and the two ways of using more than one CPU core, the cluster module and worker threads.
File System API: Sync, Callback and Promise Forms
The fs module wraps the operating system's file APIs (POSIX calls such as open, read, write, stat and rename). For historical reasons almost every operation exists in three forms, and choosing the right one is the first decision whenever you touch files.
The three forms.
// 1. Synchronous: blocks the thread, throws on error
import { readFileSync } from 'node:fs';
const a = readFileSync('notes.txt', 'utf8');
// 2. Callback: non-blocking, error-first callback
import { readFile } from 'node:fs';
readFile('notes.txt', 'utf8', (err, b) => { if (err) throw err; console.log(b); });
// 3. Promise: non-blocking, works with async/await
import { readFile as readFileP } from 'node:fs/promises';
const c = await readFileP('notes.txt', 'utf8');
| Form | Import | Blocks? | Errors | Use for |
|---|---|---|---|---|
| Sync | node:fs (*Sync) | Yes | Thrown | Start-up, CLI tools, build scripts |
| Callback | node:fs | No | First callback argument | Legacy code; slightly faster in hot paths |
| Promise | node:fs/promises | No | Rejected promise | Most new code |
Common operations (promise form).
import * as fs from 'node:fs/promises';
await fs.writeFile('out.json', JSON.stringify({ ok: true })); // create or overwrite
await fs.appendFile('app.log', `${new Date().toISOString()} started\n`);
const info = await fs.stat('out.json'); // size, mtime, isFile(), isDirectory()
console.log(info.size, info.mtime);
await fs.copyFile('out.json', 'backup.json');
await fs.rename('backup.json', 'archive/backup.json'); // also moves within a file system
await fs.unlink('archive/backup.json'); // delete a file
await fs.rm('tmp', { recursive: true, force: true }); // delete a directory tree (like rm -rf)
// Check existence by trying the operation, not with a separate check
try {
const text = await fs.readFile('maybe.txt', 'utf8');
} catch (err) {
if (err.code === 'ENOENT') console.log('file does not exist');
else throw err;
}
Error codes. File errors carry POSIX codes in err.code:
| Code | Meaning |
|---|---|
ENOENT | No such file or directory |
EACCES / EPERM | Permission denied / operation not permitted |
EEXIST | File already exists (e.g. with flag wx or mkdir without recursive) |
EISDIR / ENOTDIR | Expected a file but found a directory, or the reverse |
EMFILE | Too many open files (file descriptor limit reached) |
ENOSPC | No space left on device |
Avoid check-then-act races. Code like if (existsSync(p)) readFile(p) has a gap between the check and the action, during which another process may delete or create the file (a TOCTOU race, Time Of Check To Time Of Use). Perform the operation directly and handle the error, or use flags that make it atomic, such as { flag: 'wx' } ("create, fail if it exists").
File handles for fine-grained control. fs.open returns a FileHandle for reading or writing at specific positions, which readFile does not allow:
import { open } from 'node:fs/promises';
const fh = await open('data.bin', 'r');
try {
const { bytesRead, buffer } = await fh.read(Buffer.alloc(16), 0, 16, 1024); // 16 bytes at offset 1024
console.log(bytesRead, buffer);
} finally {
await fh.close(); // always close handles; leaked descriptors eventually cause EMFILE
}
Atomic writes. Writing directly to config.json can leave a half-written file if the process crashes mid-write. Write to a temporary file in the same directory and rename it, which is atomic on POSIX file systems:
import { writeFile, rename } from 'node:fs/promises';
async function writeJsonAtomic(path, data) {
const tmp = `${path}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(data, null, 2));
await rename(tmp, path); // readers see either the old or the new file, never half of one
}
Large files. readFile loads the entire file into memory. For large files, use streams (createReadStream, 11.4).
Key Takeaways
- Most
fsoperations exist in synchronous, callback and promise forms; preferfs/promisesin application code and sync only at start-up or in scripts. - File errors carry codes such as
ENOENT,EACCESandEEXIST; branch onerr.code. - Avoid check-then-act races: attempt the operation and handle the error, or use exclusive flags.
- Use
FileHandlefor positional I/O, temp-file-plus-rename for atomic writes, and streams for large files.
Practice
- (Beginner) Write a script that reads a JSON file, increments a
countfield (creating the file withcount: 1if it does not exist) and writes it back usingfs/promises.- (Intermediate) Implement
writeJsonAtomicand test it by killing the process (process.kill(process.pid, 'SIGKILL')) at random points during repeated writes; verify the file is always valid JSON.- (Interview) "Why is
if (fs.existsSync(file)) fs.readFileSync(file)considered an anti-pattern?" Hint: think about what can happen between the two calls, and how many system calls each approach makes.
Paths, Directories and File Watching
File paths look like simple strings, but they differ between operating systems (/ vs \, drive letters, case sensitivity) and are a classic source of bugs and security holes. Node's path module manipulates paths correctly for the current platform, and fs provides directory and watching APIs.
The path module.
import path from 'node:path';
path.join('/srv', 'app', '..', 'data', 'users.json'); // '/srv/data/users.json' (normalised)
path.resolve('uploads', 'a.png'); // absolute, based on process.cwd(): '/home/ada/project/uploads/a.png'
path.basename('/srv/data/users.json'); // 'users.json'
path.basename('/srv/data/users.json', '.json'); // 'users'
path.extname('archive.tar.gz'); // '.gz'
path.dirname('/srv/data/users.json'); // '/srv/data'
path.parse('/srv/data/users.json');
// { root: '/', dir: '/srv/data', base: 'users.json', ext: '.json', name: 'users' }
path.relative('/srv/app', '/srv/data/users.json'); // '../data/users.json'
path.isAbsolute('C:\\temp'); // true on Windows, false on POSIX
path.sep; // '/' on POSIX, '\\' on Windows
| Function | Difference |
|---|---|
path.join(...parts) | Concatenates and normalises; result is relative if the first part is |
path.resolve(...parts) | Processes right to left until an absolute path is formed, then prepends cwd if needed; always absolute |
path.posix and path.win32 force a specific platform's rules, which is useful for URL-like paths or tooling that processes paths from another OS.
Preventing path traversal. When part of a path comes from a user (an upload name, a URL), ../ sequences can escape the intended directory:
import path from 'node:path';
const ROOT = path.resolve('public');
function safeResolve(userPath) {
const full = path.resolve(ROOT, '.' + path.sep + userPath); // force it to be relative to ROOT
if (full !== ROOT && !full.startsWith(ROOT + path.sep)) { // must stay inside ROOT
throw new Error('path traversal attempt');
}
return full;
}
safeResolve('img/logo.png'); // '/srv/app/public/img/logo.png'
safeResolve('../../etc/passwd'); // throws
Directories.
import { mkdir, readdir, rm } from 'node:fs/promises';
await mkdir('logs/2026/10', { recursive: true }); // create missing parents; no error if it exists
const entries = await readdir('src', { withFileTypes: true }); // Dirent objects, no extra stat calls
for (const e of entries) {
console.log(e.isDirectory() ? 'dir ' : 'file', e.name);
}
const all = await readdir('src', { recursive: true }); // every path in the tree (Node 20+)
import { glob } from 'node:fs/promises'; // Node 22+
for await (const file of glob('src/**/*.test.js')) console.log(file);
URLs vs paths in ES modules. import.meta.url is a file: URL, not a path. Convert with fileURLToPath and pathToFileURL from node:url; never strip file:// by hand (it breaks on Windows and with special characters).
File watching. Development tools (watch mode, hot reload, test watchers) react to file changes:
import { watch } from 'node:fs/promises';
const ac = new AbortController();
try {
for await (const event of watch('src', { recursive: true, signal: ac.signal })) {
console.log(event.eventType, event.filename); // 'change' or 'rename', and the relative name
}
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
fs.watch uses OS notifications (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows), so it is efficient, but its behaviour differs between platforms: events may be duplicated, the file name may be missing, and editors that save by writing a temporary file and renaming it produce 'rename' events. Robust tools debounce events and re-read the file state, or use a library such as chokidar. fs.watchFile polls with stat instead: slower but consistent, and it works on network file systems where notifications do not.
Key Takeaways
- Use
pathfunctions instead of string concatenation;joinnormalises,resolveproduces absolute paths. - Validate that user-influenced paths stay inside an allowed root to prevent path traversal.
mkdir({ recursive: true }),readdir({ withFileTypes, recursive })andfs.globcover most directory work.fs.watchis efficient but platform-dependent; debounce events or use a library for robust watching.
Practice
- (Beginner) Use
path.parseandpath.formatto change the extension of every file name in a list from.jpegto.jpg.- (Intermediate) Write a script that prints a tree view of a directory (like the
treecommand) with file sizes, usingreaddirwithwithFileTypesand recursion.- (Interview) "A file download endpoint takes a
namequery parameter and servespath.join('files', name). What is wrong with it?" Hint: tryname=../../.env, and think about how to verify the final resolved path.
The process Object, argv and Environment
process is a global object representing the running Node.js process. It is the bridge between your program and the environment that started it: the command line, environment variables, working directory, exit status and resource usage.
Command-line arguments. process.argv is an array: the path of the Node executable, the path of the script, and then the user's arguments.
$ node greet.js --name Ada --loud
process.argv = [
'/usr/local/bin/node', // [0] the executable
'/home/ada/greet.js', // [1] the script
'--name', 'Ada', '--loud' // [2..] your arguments
]
Parsing flags by hand is error-prone. util.parseArgs (stable since Node 20) handles options, short aliases and positional arguments:
// greet.js
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
options: {
name: { type: 'string', short: 'n', default: 'world' },
loud: { type: 'boolean', short: 'l' },
},
allowPositionals: true,
});
let msg = `Hello, ${values.name}!`;
if (values.loud) msg = msg.toUpperCase();
console.log(msg, positionals);
// node greet.js -n Ada --loud extra -> HELLO, ADA! [ 'extra' ]
For larger CLIs with sub-commands, help text and validation, libraries such as commander or yargs are common.
Environment variables. process.env is an object of strings inherited from the parent process. It is the standard way to configure applications per environment (development, staging, production) without changing code, as recommended by the Twelve-Factor App methodology:
const port = Number(process.env.PORT ?? 3000); // values are always strings (or undefined)
const isProd = process.env.NODE_ENV === 'production';
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
PORT=8080 NODE_ENV=production node server.js # set for one command (POSIX shells)
node --env-file=.env server.js # load variables from a file (Node 20.6+)
# .env — never commit real secrets; commit a .env.example with placeholders instead
PORT=8080
DATABASE_URL=postgres://app:secret@localhost:5432/app
Validate configuration once at start-up and fail fast: a missing variable should stop the process immediately with a clear message, not cause a confusing error an hour later. A schema library (such as zod) is often used for this. NODE_ENV=production also changes behaviour in many libraries (Express disables verbose errors and enables caching), so set it in production.
Exiting. A process ends with an exit code: 0 for success, anything else for failure. Shells, CI systems and process managers use it to decide what happened.
process.exitCode = 1; // preferred: set the code and let the process finish naturally (flushes output)
process.exit(1); // immediate: pending I/O, such as stdout writes to a pipe, may be lost
Other useful properties and events.
| Member | Purpose |
|---|---|
process.cwd(), process.chdir(dir) | Current working directory |
process.pid, process.ppid | Process ID and parent's ID |
process.platform, process.arch | 'linux', 'darwin', 'win32'; 'x64', 'arm64' |
process.memoryUsage() | rss, heapUsed, heapTotal, external in bytes |
process.uptime(), process.hrtime.bigint() | Seconds since start; nanosecond-precision clock for benchmarks |
process.on('exit', fn) | Last chance to run synchronous code before exiting |
process.on('beforeExit', fn) | The loop is empty; you may schedule more work |
process.on('uncaughtException', fn) | An exception reached the top of the stack |
process.on('unhandledRejection', fn) | A promise rejected with no handler |
process.on('warning', fn) | Deprecations and other warnings |
Uncaught exceptions. After an uncaughtException, the application is in an unknown state (a half-processed request, a lock never released). The handler should log the error and exit, letting a process manager restart the service, not continue as if nothing happened:
process.on('uncaughtException', (err, origin) => {
console.error({ err, origin }, 'fatal error, exiting');
process.exit(1); // a supervisor (systemd, Kubernetes, PM2) restarts the process
});
Key Takeaways
process.argvholds command-line arguments from index 2; parse them withutil.parseArgsor a CLI library.process.envcarries configuration as strings; validate it at start-up and fail fast;--env-fileloads.envfiles.- Exit codes report success (0) or failure (non-zero); prefer
process.exitCodetoprocess.exit()so output is flushed. - After an uncaught exception, log and exit; rely on a supervisor to restart.
Practice
- (Beginner) Write a script
sum.jsthat adds all numeric arguments (node sum.js 1 2 3.5) and exits with code 1 and a message if any argument is not a number.- (Intermediate) Write a
config.jsmodule that readsPORT,DATABASE_URLandLOG_LEVELfrom the environment, applies defaults, validates types and allowed values, and exits with a helpful message listing all problems at once.- (Interview) "Why is continuing to run after an
uncaughtExceptiondangerous?" Hint: think about what was in progress when the exception was thrown: open transactions, locks, partially updated in-memory data.
Signals and Graceful Shutdown
Servers are stopped all the time: on every deployment, when an orchestrator scales down, when a machine is drained for maintenance. If the process simply dies, in-flight requests fail, database transactions are interrupted and queued messages may be lost. A graceful shutdown stops accepting new work, finishes the current work, releases resources and then exits.
Signals. On Unix-like systems, the OS and other processes communicate with a process through signals: small numbered notifications. The ones that matter most:
| Signal | Sent by | Default action | Can be handled? |
|---|---|---|---|
SIGINT | Ctrl+C in a terminal | Terminate | Yes |
SIGTERM | kill <pid>, Docker stop, Kubernetes, systemd | Terminate | Yes |
SIGHUP | Terminal closed; by convention "reload configuration" | Terminate | Yes |
SIGKILL | kill -9, orchestrators after a grace period | Terminate immediately | No |
SIGUSR1 | User-defined | Node uses it to start the inspector | Reserved by Node |
Windows has no real signals; Node emulates SIGINT (Ctrl+C) and SIGBREAK, and SIGTERM handlers are not triggered by normal termination there.
The shutdown sequence.
SIGTERM received
|
v
1. stop accepting new connections server.close()
2. fail health/readiness checks load balancer stops sending traffic
3. finish in-flight requests with a deadline
4. close keep-alive / idle connections server.closeIdleConnections()
5. close resources database pools, message consumers, file handles
6. exit with code 0 or 1 if the deadline was exceeded
|
v
orchestrator sends SIGKILL if still running after its grace period (Kubernetes: 30 s by default)
Implementation.
import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
await new Promise(r => setTimeout(r, 2000)); // simulate a slow request
res.end('done\n');
});
server.listen(3000, () => console.log('listening on 3000'));
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return; // ignore repeated signals
shuttingDown = true;
console.log(`${signal} received, shutting down`);
const forceExit = setTimeout(() => {
console.error('shutdown timed out, forcing exit');
process.exit(1);
}, 10_000);
forceExit.unref(); // the timer itself must not keep the process alive
server.close(async (err) => { // callback runs when all connections have ended
// close other resources here, e.g. `await db.end()` for a database pool, message consumers, ...
console.log('clean shutdown');
process.exit(err ? 1 : 0);
});
server.closeIdleConnections(); // idle keep-alive sockets would otherwise delay close()
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Once a handler for SIGINT or SIGTERM is installed, Node no longer exits automatically on that signal: your handler is now responsible for exiting.
Containers and PID 1. In Docker, the first process in the container (PID 1) does not get default signal handling from the kernel. If you start Node through a shell (CMD npm start or CMD sh -c "node server.js"), the shell may not forward SIGTERM to Node, and the container is killed after the timeout. Remedies:
- Use the exec form and run Node directly:
CMD ["node", "server.js"]. - Run with an init process:
docker run --initortini. - Handle
SIGTERMin your code as shown above.
Health checks during shutdown. Behind a load balancer there is a short window in which traffic is still routed to an instance that has begun shutting down. A common pattern is to make the readiness endpoint return 503 as soon as shutdown starts and wait a few seconds before closing the server.
Key Takeaways
- Processes are stopped with signals:
SIGTERM(orchestrators),SIGINT(Ctrl+C);SIGKILLcannot be caught. - Graceful shutdown: stop accepting work, finish in-flight work with a deadline, close resources, exit.
server.close()stops new connections and waits for existing ones; close idle keep-alive connections too.- In containers, run Node as PID 1 with the exec form or an init process so signals reach it.
Practice
- (Beginner) Write a script that prints "tick" every second and, on Ctrl+C, prints "bye" and exits with code 0. Then send it
SIGTERMwithkillfrom another terminal.- (Intermediate) Run the graceful-shutdown server above, start a slow request with
curl, and sendSIGTERMduring it. Verify the request completes and the process exits. Then repeat with the server in a Docker container started withCMD npm startand observe the difference.- (Interview) "During deployments, some users see connection errors. How would you make deployments zero-downtime for a Node.js service?" Hint: think about signal handling, readiness checks, the load balancer's view of the instance and keep-alive connections.
Standard Streams and CLI Input/Output
Every process starts with three open streams: standard input (stdin), standard output (stdout) and standard error (stderr). Command-line tools are composed with shell pipes and redirections, so a well-behaved Node CLI reads from stdin, writes results to stdout and writes diagnostics to stderr.
+-----------------+
keyboard / | | --- stdout (fd 1) --> terminal, file, next program
file / pipe -->| node tool.js |
stdin (fd 0) | | --- stderr (fd 2) --> terminal (errors, progress, logs)
+-----------------+
cat data.csv | node tool.js > result.json 2> errors.log
In Node. process.stdin is a Readable, process.stdout and process.stderr are Writables. console.log writes to stdout, console.error and console.warn to stderr.
// upper.js — a filter: read stdin, write transformed text to stdout
import { pipeline } from 'node:stream/promises';
await pipeline(
process.stdin,
async function* (source) {
for await (const chunk of source) yield chunk.toString().toUpperCase();
},
process.stdout,
);
// echo "hello" | node upper.js -> HELLO
Why stdout vs stderr matters. If progress messages go to stdout, they corrupt the data when output is redirected to a file or piped into another program. Rule: data to stdout, everything else to stderr.
Interactive input. readline/promises asks questions in a terminal:
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const rl = createInterface({ input, output });
const name = await rl.question('Project name? ');
const ok = await rl.question(`Create "${name}"? (y/N) `);
rl.close();
if (ok.trim().toLowerCase() !== 'y') process.exitCode = 1;
Detecting terminals. process.stdout.isTTY is true when output goes to a terminal and undefined when it is redirected. Tools use this to decide whether to print colours, progress bars and prompts:
import { styleText } from 'node:util';
const useColor = process.stdout.isTTY && !process.env.NO_COLOR; // respect the NO_COLOR convention
const label = useColor ? styleText('green', 'OK') : 'OK';
console.log(`${label} 12 files processed`);
if (!process.stdin.isTTY) {
// input is piped: read it instead of prompting
}
Exit codes and composability. Return 0 on success and non-zero on failure so that scripts such as node lint.js && node build.js and CI pipelines behave correctly. Use distinct codes for distinct failures if callers need them (for example 1 for "problems found", 2 for "invalid usage").
Making a CLI installable. Add a bin field and a shebang line, then npm link (during development) or npm install -g installs a command:
{
"name": "csv2json",
"type": "module",
"bin": { "csv2json": "./bin/csv2json.js" }
}
#!/usr/bin/env node
// bin/csv2json.js
import { createInterface } from 'node:readline';
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
let headers;
for await (const line of rl) {
if (!line.trim()) continue;
const cells = line.split(','); // naive CSV: no quoted fields
if (!headers) { headers = cells; continue; }
process.stdout.write(JSON.stringify(Object.fromEntries(headers.map((h, i) => [h, cells[i]]))) + '\n');
}
Key Takeaways
- stdin is a Readable; stdout and stderr are Writables; they can be redirected and piped by the shell.
- Write data to stdout and diagnostics to stderr so output stays usable in pipelines.
- Use
readline/promisesfor prompts andisTTYto adapt colours, progress output and prompts. - Return meaningful exit codes, and use
binplus a shebang to ship installable commands.
Practice
- (Beginner) Write a script that reads numbers (one per line) from stdin and prints their sum to stdout. Test it with
seq 1 100 | node sum.js.- (Intermediate) Extend
csv2jsonso that it prints a progress count to stderr only when stderr is a TTY, accepts an optional input file argument (falling back to stdin) and exits with code 2 on invalid usage.- (Interview) "Why should a command-line tool print errors and progress messages to stderr instead of stdout?" Hint: think about
tool > out.jsonandtool | jq.
Spawning Child Processes and Shell Commands
Sometimes the best tool for a job is another program: git, ffmpeg, convert, a Python script, a compiler. The child_process module starts other programs, communicates with them through their standard streams and waits for them to finish. It also provides fork for running other Node scripts with a message channel.
The four main functions.
| Function | Shell? | Output | Best for |
|---|---|---|---|
spawn(cmd, args, opts) | No (unless shell: true) | Streams | Long-running processes, large output |
execFile(file, args, cb) | No | Buffered (stdout, stderr strings) | Short commands with small output |
exec(command, cb) | Yes | Buffered | Shell features (pipes, globs) with trusted input only |
fork(modulePath, args) | No | Streams + IPC channel | Other Node.js scripts that exchange messages |
Each has a *Sync variant (execFileSync, spawnSync, ...) for scripts.
execFile for simple commands.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
const { stdout } = await run('git', ['log', '-1', '--format=%H %s']);
console.log('last commit:', stdout.trim());
spawn for streaming output and long-running processes.
import { spawn } from 'node:child_process';
const child = spawn('ffmpeg', ['-i', 'input.mov', '-vf', 'scale=1280:-2', 'output.mp4'], {
stdio: ['ignore', 'pipe', 'pipe'], // stdin ignored, stdout and stderr as streams
});
child.stderr.on('data', (chunk) => process.stderr.write(chunk)); // ffmpeg reports progress on stderr
child.on('error', (err) => console.error('failed to start:', err.message)); // e.g. ENOENT: not installed
child.on('close', (code, signal) => {
console.log(code === 0 ? 'converted' : `ffmpeg failed (code ${code}, signal ${signal})`);
});
The stdio option controls the child's streams: 'pipe' (accessible from the parent), 'inherit' (share the parent's terminal, useful for build scripts), 'ignore', or a file descriptor. cwd, env, timeout and signal (an AbortSignal that kills the child) are other common options.
Shell injection. exec passes a string to /bin/sh (or cmd.exe). If any part of that string comes from a user, they can append their own commands:
import { exec, execFile } from 'node:child_process';
const filename = req.query.file; // attacker sends: "x.txt; rm -rf ~"
exec(`wc -l ${filename}`, cb); // DANGEROUS: the shell runs `rm -rf ~`
execFile('wc', ['-l', filename], cb); // SAFE: filename is a single argument; no shell parses it
Prefer execFile or spawn with an argument array. If you need a shell feature, build the pipeline in Node (connect two spawned processes' streams) instead of passing user input to a shell. Also beware of arguments beginning with -, which some programs interpret as options; -- usually ends option parsing.
fork and IPC. fork starts a new Node process running a module, with an IPC (inter-process communication) channel for send()/'message':
// parent.js
import { fork } from 'node:child_process';
const child = fork('./worker.js');
child.send({ task: 'fib', n: 40 });
child.on('message', (msg) => {
console.log('result:', msg.result);
child.kill();
});
// worker.js
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
process.on('message', ({ task, n }) => {
if (task === 'fib') process.send({ result: fib(n) }); // runs in a separate process: parent stays responsive
});
Forked processes are fully isolated (separate memory, separate V8 instance), which makes them robust but heavier than worker threads: each costs tens of megabytes and noticeable start-up time.
Key Takeaways
spawnstreams output and suits long-running processes;execFilebuffers output for short commands;forkruns Node modules with an IPC channel.execruns through a shell: never interpolate untrusted input into it; use argument arrays withexecFileorspawn.- Always handle
'error'(the program may not exist) and check the exit code and signal in'close'. - Child processes are isolated and robust but heavier than worker threads.
Practice
- (Beginner) Use
execFileto runnode --versionandnpm --versionand print a one-line summary of both.- (Intermediate) Recreate the shell pipeline
ls -la | grep ".js"by spawning two processes and piping the first one's stdout into the second one's stdin, without using a shell.- (Interview) "What is the difference between
execandexecFile, and why does it matter for security?" Hint: consider who parses the command string and what characters like;,&&and$()mean to that parser.
The os and util Modules
Two small core modules come up constantly in real programs. os describes the machine the process runs on; util collects utilities that do not belong anywhere else: promisification, inspection, formatting, type checks, deprecation and argument parsing.
The os module.
import os from 'node:os';
os.platform(); // 'linux', 'darwin', 'win32' (same as process.platform)
os.type(); // 'Linux', 'Darwin', 'Windows_NT'
os.release(); // kernel version
os.arch(); // 'x64', 'arm64'
os.hostname();
os.homedir(); // '/home/ada' or 'C:\\Users\\ada'
os.tmpdir(); // '/tmp' or the Windows temp folder
os.EOL; // '\n' on POSIX, '\r\n' on Windows
os.availableParallelism();// number of CPUs usable by this process (Node 18.14+), better than os.cpus().length
os.cpus(); // model and speed of each logical CPU
os.totalmem(); // bytes
os.freemem(); // bytes
os.loadavg(); // [1, 5, 15]-minute load averages (always [0, 0, 0] on Windows)
os.uptime(); // seconds since boot
os.networkInterfaces(); // IPv4/IPv6 addresses per interface
os.userInfo(); // { username, uid, gid, shell, homedir }
Typical uses: choosing a worker count (availableParallelism()), writing temporary files to tmpdir(), building per-user config paths from homedir(), and reporting machine information in health endpoints. In containers, note that totalmem() and cpus() may report the host's resources rather than the container's limits; availableParallelism() and process.constrainedMemory() are more container-aware.
The util module: most useful functions.
| Function | Purpose |
|---|---|
util.promisify(fn) / util.callbackify(fn) | Convert between error-first callbacks and promises (11.3) |
util.inspect(obj, opts) | Detailed string representation (what console.log uses); depth, colors, compact |
util.format(fmt, ...args) | printf-style formatting: %s, %d, %j, %o |
util.parseArgs(config) | Command-line argument parsing (11.5, "The process Object") |
util.styleText(format, text) | ANSI colours and styles for terminal output |
util.deprecate(fn, msg, code) | Wrap a function to print a deprecation warning once |
util.types.* | Reliable type checks: isPromise, isDate, isRegExp, isProxy, ... |
util.isDeepStrictEqual(a, b) | Structural equality, as used by assert.deepStrictEqual |
util.debuglog(section) | Conditional debug logging enabled with NODE_DEBUG=section |
util.inherits | Legacy prototype inheritance; use class extends instead |
import util from 'node:util';
const nested = { a: { b: { c: { d: { e: 1 } } } } };
console.log(nested); // { a: { b: { c: [Object] } } } (default depth 2)
console.log(util.inspect(nested, { depth: null, colors: true })); // the full object
util.format('%s has %d items: %j', 'cart', 3, ['a', 'b']); // 'cart has 3 items: ["a","b"]'
export const oldApi = util.deprecate(
() => newApi(),
'oldApi() is deprecated, use newApi() instead',
'DEP_MYLIB_001', // code: shown once per process
);
const debug = util.debuglog('payments'); // prints only with NODE_DEBUG=payments
debug('charging card %s', '****4242');
util.isDeepStrictEqual({ x: [1, 2] }, { x: [1, 2] }); // true
Custom inspection. Classes can control how they appear in logs, for example to hide secrets:
class ApiClient {
constructor(token) { this.token = token; this.baseUrl = 'https://api.example.com'; }
[Symbol.for('nodejs.util.inspect.custom')]() {
return `ApiClient { baseUrl: '${this.baseUrl}', token: '[REDACTED]' }`;
}
}
console.log(new ApiClient('sk_live_123')); // ApiClient { baseUrl: 'https://api.example.com', token: '[REDACTED]' }
Key Takeaways
osreports platform, CPUs, memory, directories, line endings and network interfaces; useavailableParallelism()for worker counts.- In containers, host-level values from
oscan differ from the container's actual limits. utilprovidespromisify,inspect,format,parseArgs,styleText,deprecate,typesanddebuglog.util.inspect.customcontrols how objects appear in logs, which helps keep secrets out of them.
Practice
- (Beginner) Write a
sysinfo.jsscript that prints the platform, architecture, CPU count, total and free memory in GB, and uptime in hours.- (Intermediate) Add
util.debuglogstatements to a small module, show that they are silent by default and appear withNODE_DEBUG, and add a custom inspect method to a class holding a password.- (Interview) "Your service sizes its worker pool with
os.cpus().lengthand performs badly in Kubernetes. Why might that be?" Hint: compare the CPUs visible on the host node with the CPU limit assigned to the container.
Cluster Module and Multi-Core Scaling
One Node process runs JavaScript on one core. A server with 16 cores running a single Node process leaves 15 of them idle (apart from the thread pool and GC). The simplest way to use all cores for an HTTP server is to run several Node processes that share the same port. The cluster module does exactly this.
How it works. A primary process forks workers (separate Node processes running the same script). Workers call listen() as usual, but the primary owns the listening socket and distributes incoming connections among them.
clients
|
port 3000
|
+-------------------+
| primary process | accepts connections, distributes them
| (no app logic) | (round-robin by default except on Windows)
+---------+---------+
+---------------+---------------+---------------+
v v v v
[worker 1] [worker 2] [worker 3] [worker 4] each: own V8 heap, own event loop
app.js app.js app.js app.js no shared memory
// cluster.js
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import { createServer } from 'node:http';
if (cluster.isPrimary) {
const n = availableParallelism();
console.log(`primary ${process.pid} starting ${n} workers`);
for (let i = 0; i < n; i++) cluster.fork();
cluster.on('exit', (worker, code, signal) => {
console.warn(`worker ${worker.process.pid} died (${signal ?? code}), starting a new one`);
cluster.fork(); // keep the pool at full size
});
} else {
createServer((req, res) => {
res.end(`handled by worker ${process.pid}\n`);
}).listen(3000); // all workers share port 3000
}
for i in 1 2 3 4; do curl -s localhost:3000; done
# handled by worker 41237
# handled by worker 41238
# handled by worker 41239
# handled by worker 41240
Consequences of separate processes.
- No shared memory. An in-memory cache, rate limiter or session store exists separately in each worker. Requests from the same user can hit different workers, so shared state must live in an external store such as Redis (Chapter 13) or a database.
- WebSockets and long-lived connections stay on one worker. Broadcasting to all clients requires a pub/sub channel between workers. Protocols that need several HTTP requests from one client to reach the same worker (some Socket.IO fallbacks) need sticky sessions.
- Memory multiplies: 8 workers use roughly 8 times the memory of one.
- Fault isolation: a crash kills only one worker; the primary replaces it.
Zero-downtime reloads. Because workers are independent, the primary can restart them one at a time on a new code version (send disconnect, wait for the worker to finish, fork a replacement), so the port is never left without a listener.
Cluster vs alternatives.
| Approach | How it scales | Notes |
|---|---|---|
cluster module | Several processes on one machine, one port | Built in, manual supervision code |
PM2 (pm2 start app.js -i max) | Same idea, managed by a process manager | Adds restarts, logs, monitoring, rolling reloads |
| One process per container, many containers | The orchestrator (Kubernetes, ECS) and a load balancer distribute traffic | The usual approach in containerised deployments; keeps each container simple |
| Worker threads | Threads within one process for CPU tasks | Not for scaling HTTP handling across cores (next topic) |
In containerised environments, the common recommendation is one Node process per container with the container's CPU limit around one core, scaled by adding replicas, so that the orchestrator handles restarts, placement and scaling uniformly.
Key Takeaways
clusterforks several Node processes that share one listening port, using all CPU cores.- Workers share no memory: keep sessions, caches and rate-limit counters in an external store.
- The primary should restart crashed workers; independent workers allow rolling, zero-downtime reloads.
- In containers, scaling with more single-process replicas is usually preferred over clustering inside one container.
Practice
- (Beginner) Run the cluster example, send requests with
curlin a loop and observe the distribution. Kill one worker withkill <pid>and verify that a replacement starts.- (Intermediate) Add an in-memory request counter to each worker and an endpoint that returns it. Show why the counts are inconsistent across requests, and outline how you would make the count global.
- (Interview) "How do you scale a Node.js HTTP service across all cores of a machine, and what changes in the application design?" Hint: compare cluster, a process manager and multiple containers, and think about state stored in memory.
Worker Threads for CPU-Bound Tasks
Clustering scales request handling, but it does not help when a single request needs heavy computation: that request still blocks its worker's event loop. Worker threads (node:worker_threads) run JavaScript in parallel threads within one process, so the main thread can hand off CPU-heavy work and stay responsive. They are Node's equivalent of the browser's Web Workers (Chapter 6.4).
The model. Each worker has its own V8 isolate, its own event loop and its own module instances. Workers do not share ordinary JavaScript objects with the main thread; they communicate by message passing, where data is copied using the structured clone algorithm, transferred (ownership moves, no copy) or explicitly shared through SharedArrayBuffer.
main thread (event loop serving HTTP) worker thread (own event loop, own heap)
------------------------------------ --------------------------------------
worker.postMessage({ n: 45 }) ---- copy ----> parentPort.on('message', ...)
heavy computation (does not block main)
worker.on('message', result) <--- copy ----- parentPort.postMessage(result)
A minimal example.
// fib-worker.js
import { parentPort } from 'node:worker_threads';
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
parentPort.on('message', (n) => {
parentPort.postMessage(fib(n)); // CPU-heavy, but on this thread, not the main one
});
// server.js
import { Worker } from 'node:worker_threads';
import { createServer } from 'node:http';
function runFib(n) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./fib-worker.js', import.meta.url));
worker.once('message', (result) => { resolve(result); worker.terminate(); });
worker.once('error', reject);
worker.postMessage(n);
});
}
createServer(async (req, res) => {
if (req.url === '/fib') {
const result = await runFib(42); // main thread stays free while the worker computes
return res.end(`fib(42) = ${result}\n`);
}
res.end('fast response\n'); // still answered instantly during computations
}).listen(3000);
Use a pool, not a worker per task. Starting a worker takes milliseconds and several megabytes of memory. For a server, create a fixed pool of workers (roughly one per available core) at start-up and queue tasks to them. Libraries such as Piscina implement pools with queues, cancellation and resource limits:
import Piscina from 'piscina';
const pool = new Piscina({ filename: new URL('./resize-task.js', import.meta.url).href });
const thumbnail = await pool.run({ path: 'photo.jpg', width: 300 }); // queued if all workers are busy
Passing data efficiently.
| Method | Behaviour | Use when |
|---|---|---|
postMessage(obj) | Structured clone (deep copy) | Small or moderate data |
postMessage(buf, [buf.buffer]) | Transfer: zero-copy, the sender loses access | Large binary data passed one way |
SharedArrayBuffer + Atomics | Truly shared memory, synchronised with atomic operations | High-frequency sharing of numeric data |
workerData option | Data cloned once at start-up | Configuration for the worker |
const pixels = new Uint8Array(50_000_000); // 50 MB of image data
worker.postMessage(pixels, [pixels.buffer]); // moved, not copied; pixels is now empty here
Worker threads vs other options.
| Need | Tool |
|---|---|
| Wait on many I/O operations | Nothing extra: the event loop already handles it |
| Run CPU-heavy JavaScript without blocking requests | Worker threads (pooled) |
| Use all cores for HTTP handling | Cluster, PM2 or more containers |
| Run another program or isolate untrusted/fragile code | Child processes |
| Very heavy or long jobs (video encoding, ML) | A separate service or job queue |
Worker threads do not speed up I/O; using them for database queries or HTTP calls only adds overhead.
Key Takeaways
- Worker threads run JavaScript in parallel inside one process, each with its own isolate and event loop.
- Communicate with
postMessage: data is cloned, transferred or shared throughSharedArrayBuffer. - Workers are costly to start: use a pool (for example Piscina) sized to the available cores.
- Use them for CPU-bound work only; I/O is already handled efficiently by the main event loop.
Practice
- (Beginner) Create a server with a
/hashroute that runscrypto.pbkdf2Sync20 times. Measure how it affects a/pingroute, then move the work to a worker thread and measure again.- (Intermediate) Implement a minimal worker pool with a fixed number of workers and a task queue, returning a promise per task, and compare its throughput with creating a new worker for each task.
- (Interview) "What is the difference between worker threads, child processes and the cluster module?" Hint: compare memory isolation, start-up cost, communication mechanism and the problem each is designed to solve.
<a id="116-http-apis-in-nodejs"></a>
11.6 HTTP APIs in Node.js
This subchapter builds web servers and clients with Node's built-in modules only: the http server and its request and response objects, manual routing and body parsing, static file serving with streams, outgoing requests with fetch and http.request, HTTPS and TLS, file uploads, and logging, error handling and debugging. Frameworks such as Express (Chapter 12) are thin layers over exactly these APIs, so understanding them explains what frameworks do for you.
Creating a Server with the http Module
The HTTP protocol (Chapter 10) is text over TCP: a client sends a request, the server sends a response. The node:http module implements the protocol for you: it accepts TCP connections, parses requests with the llhttp parser, and gives your code a callback per request, with objects for reading the request and writing the response.
The smallest server.
// server.js
import { createServer } from 'node:http';
const server = createServer((req, res) => { // called once for every request
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('Hello from Node.js\n'); // send the body and finish the response
});
server.listen(3000, '127.0.0.1', () => {
console.log('listening on http://127.0.0.1:3000');
});
node server.js
curl -i http://127.0.0.1:3000
# HTTP/1.1 200 OK
# Content-Type: text/plain; charset=utf-8
# Date: Sat, 03 Oct 2026 10:00:00 GMT
# Connection: keep-alive
# Keep-Alive: timeout=5
# Content-Length: 19
#
# Hello from Node.js
What happens underneath.
client Node.js process
| TCP connect |
|----------------------------->| 'connection' event (net.Socket)
| GET / HTTP/1.1 ... |
|----------------------------->| llhttp parses the request line and headers
| | 'request' event -> your callback(req, res)
| | res.setHeader / res.end
| HTTP/1.1 200 OK ... |
|<-----------------------------|
| (connection kept alive for the next request, closed after the keep-alive timeout)
createServer(handler) is shorthand for creating a server and adding handler as a 'request' listener: http.Server is an EventEmitter (11.4).
Host and port. listen(port, host) decides who can connect. '127.0.0.1' (or 'localhost') accepts connections only from the same machine; '0.0.0.0' (IPv4) or '::' (IPv6 and usually IPv4) accepts connections on all interfaces, which is required inside containers. Port 0 asks the OS for any free port, useful in tests (server.address().port tells you which).
Server events and settings.
| Event / property | Purpose |
|---|---|
'listening' | The server is bound and ready |
'error' | E.g. EADDRINUSE (port already taken), EACCES (port < 1024 without privileges) |
'connection' | A new TCP socket |
'clientError' | A client sent a malformed request; reply with 400 and destroy the socket |
'close' | The server stopped listening and all connections have ended |
server.keepAliveTimeout | How long idle keep-alive connections stay open (default 5 s) |
server.headersTimeout, server.requestTimeout | Limits that protect against slow clients (Slowloris-style attacks) |
server.maxHeadersCount, maxHeaderSize option | Limits on header size and count |
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error('port 3000 is already in use');
process.exit(1);
}
throw err;
});
Behind a load balancer. If a load balancer reuses connections to Node, its idle timeout must be shorter than server.keepAliveTimeout. Otherwise Node may close a connection at the same instant the load balancer sends a new request on it, which produces intermittent 502 errors.
HTTP/2. The separate node:http2 module supports HTTP/2 (multiplexing, header compression). Browsers require TLS for HTTP/2, so it is used with http2.createSecureServer; in practice, HTTP/2 to clients is often terminated by a reverse proxy or CDN in front of Node.
Key Takeaways
http.createServer(handler)callshandler(req, res)once per request;listen(port, host)binds the socket.- The server is an
EventEmitter: handle'error'(for exampleEADDRINUSE) and'clientError'. - Bind to
127.0.0.1for local-only access and0.0.0.0/::in containers; port0picks a free port. - Timeouts (
keepAliveTimeout,headersTimeout,requestTimeout) protect the server and must be coordinated with load balancers.
Practice
- (Beginner) Write a server that responds with the current time as plain text, run it on port 0 and print the port it was assigned.
- (Intermediate) Start two instances of the same server on the same port and handle the resulting error with a clear message. Then use
ncortelnetto send a malformed request and handle it in'clientError'.- (Interview) "What does Node.js do between a TCP connection arriving and your request handler being called?" Hint: follow the socket through libuv's poll phase, the HTTP parser and the server's
'request'event.
IncomingMessage and ServerResponse
The two arguments of a request handler are the server's view of the HTTP exchange: req is an http.IncomingMessage, a Readable stream with the parsed request line and headers; res is an http.ServerResponse, a Writable stream with methods for the status line and headers.
Reading the request.
import { createServer } from 'node:http';
createServer((req, res) => {
console.log(req.method); // 'GET', 'POST', ...
console.log(req.url); // path + query only: '/search?q=node&page=2'
console.log(req.httpVersion); // '1.1'
console.log(req.headers); // { host: 'localhost:3000', 'user-agent': 'curl/8.9.1', ... }
console.log(req.headers['content-type']); // header names are lowercased
console.log(req.rawHeaders); // original case and order, as a flat [name, value, ...] array
console.log(req.socket.remoteAddress); // client IP (or the proxy's IP when behind one)
res.end();
}).listen(3000);
Parsing the URL. req.url is only the request target. Combine it with a base to use the WHATWG URL parser:
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
url.pathname; // '/search'
url.searchParams.get('q'); // 'node'
Number(url.searchParams.get('page') ?? 1); // 2
url.searchParams.getAll('tag'); // repeated parameters: ?tag=a&tag=b -> ['a', 'b']
Reading the body. The body is not available as a property: req is a stream, and the body arrives in chunks. You must read it, and you should limit its size, or a client can exhaust your memory:
async function readBody(req, limit = 1_000_000) { // 1 MB default limit
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) {
const err = new Error('payload too large');
err.status = 413;
throw err;
}
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
}
Writing the response. There are two styles for the status line and headers:
// Style 1: set properties and headers, then write the body
res.statusCode = 201;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Location', '/api/orders/42');
res.end(JSON.stringify({ id: 42 }));
// Style 2: writeHead sends status and headers at once
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
| Method / property | Purpose |
|---|---|
res.statusCode, res.statusMessage | Status line |
res.setHeader(name, value), getHeader, removeHeader, hasHeader | Manage headers before they are sent; value can be an array (e.g. several Set-Cookie) |
res.writeHead(status, headers) | Send the status line and headers immediately |
res.write(chunk) | Send part of the body; the first call sends the headers |
res.end([chunk]) | Finish the response; must be called exactly once |
res.headersSent | true once headers have gone out; after that they cannot change |
res.flushHeaders() | Send headers early (e.g. for Server-Sent Events) |
Body framing. If you call res.end(body) with a complete body and have not set Content-Length, Node computes it for you. If you call res.write() several times, Node uses Transfer-Encoding: chunked (Chapter 10.1), so you can stream a response of unknown length:
res.writeHead(200, { 'Content-Type': 'text/plain' });
let n = 0;
const timer = setInterval(() => {
res.write(`line ${++n}\n`); // each write is sent as a chunk
if (n === 5) { clearInterval(timer); res.end(); }
}, 500);
req.on('close', () => clearInterval(timer)); // stop if the client disconnects
Common mistakes.
- Forgetting
res.end(): the client waits until it times out. - Calling
setHeaderafter the body has started:ERR_HTTP_HEADERS_SENT. - Calling
res.end()twice (for example, missing areturnafter sending an error). - Trusting
req.headers['content-length']instead of counting bytes read. - Using
req.socket.remoteAddressfor the client IP behind a proxy, where the real address is inX-Forwarded-For(only trust it if the proxy is yours).
Key Takeaways
req(IncomingMessage) is a Readable stream withmethod,url, lowercaseheadersand the socket.- Parse
req.urlwithnew URL(req.url, base); read the body as a stream and enforce a size limit. res(ServerResponse) is a Writable: set status and headers, thenwrite()andend()exactly once.- Without
Content-Length, multiple writes are sent with chunked transfer encoding, enabling streamed responses.
Practice
- (Beginner) Write an "echo" server that responds with a JSON object containing the request's method, path, query parameters and headers.
- (Intermediate) Add a
POST /echoroute that reads a JSON body with a 10 KB limit, responds 413 when the limit is exceeded and 400 for invalid JSON, and test both cases withcurl.- (Interview) "Why is the request body in Node's
httpmodule not available as a simple property likereq.body?" Hint: think about bodies that are larger than memory, uploads that arrive slowly, and who decides how to parse and limit the body.
Manual Routing and Request Parsing
A real API serves many endpoints: GET /api/products, POST /api/orders, GET /api/orders/42. The http module calls one function for every request; deciding which code handles which method and path is routing. Writing a small router by hand shows exactly what frameworks automate.
A naive router. A chain of if statements works for two routes and becomes unmanageable at twenty:
if (req.method === 'GET' && url.pathname === '/api/products') { /* ... */ }
else if (req.method === 'POST' && url.pathname === '/api/orders') { /* ... */ }
else if (req.method === 'GET' && url.pathname.startsWith('/api/orders/')) { /* parse the id... */ }
A table-driven router with path parameters. URLPattern (a web standard, global in Node 24+) or a small regular-expression converter matches patterns such as /api/orders/:id:
// router.js
export class Router {
#routes = [];
add(method, pattern, handler) {
// '/api/orders/:id' -> /^\/api\/orders\/(?<id>[^/]+)$/
const regex = new RegExp(
'^' + pattern.replace(/\//g, '\\/').replace(/:(\w+)/g, '(?<$1>[^/]+)') + '$'
);
this.#routes.push({ method, regex, handler });
return this;
}
get(p, h) { return this.add('GET', p, h); }
post(p, h) { return this.add('POST', p, h); }
delete(p, h) { return this.add('DELETE', p, h); }
match(method, pathname) {
let pathMatched = false;
for (const route of this.#routes) {
const m = route.regex.exec(pathname);
if (!m) continue;
pathMatched = true;
if (route.method === method) return { handler: route.handler, params: m.groups ?? {} };
}
return { status: pathMatched ? 405 : 404 }; // path exists with another method -> 405
}
}
Using it, with JSON helpers and centralised errors.
// server.js
import { createServer } from 'node:http';
import { Router } from './router.js';
const orders = new Map();
let nextId = 1;
function sendJson(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body), // bytes, not characters
});
res.end(body);
}
async function readJson(req, limit = 100_000) {
if (!req.headers['content-type']?.startsWith('application/json')) {
throw Object.assign(new Error('expected application/json'), { status: 415 });
}
let size = 0; const chunks = [];
for await (const c of req) {
if ((size += c.length) > limit) throw Object.assign(new Error('payload too large'), { status: 413 });
chunks.push(c);
}
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
catch { throw Object.assign(new Error('invalid JSON'), { status: 400 }); }
}
const router = new Router()
.get('/api/orders', (req, res) => sendJson(res, 200, [...orders.values()]))
.get('/api/orders/:id', (req, res, { id }) => {
const order = orders.get(Number(id));
if (!order) return sendJson(res, 404, { error: 'order not found' });
sendJson(res, 200, order);
})
.post('/api/orders', async (req, res) => {
const input = await readJson(req);
if (!Number.isInteger(input.productId) || !(input.quantity > 0)) {
return sendJson(res, 422, { error: 'productId (integer) and quantity (> 0) are required' });
}
const order = { id: nextId++, ...input, createdAt: new Date().toISOString() };
orders.set(order.id, order);
res.setHeader('Location', `/api/orders/${order.id}`);
sendJson(res, 201, order);
});
createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const match = router.match(req.method, url.pathname);
try {
if (!match.handler) return sendJson(res, match.status, { error: match.status === 405 ? 'method not allowed' : 'not found' });
await match.handler(req, res, match.params, url);
} catch (err) {
const status = err.status ?? 500;
if (status === 500) console.error(err); // log unexpected errors with stack trace
if (!res.headersSent) sendJson(res, status, { error: status === 500 ? 'internal error' : err.message });
else res.destroy(err); // response already started: abort it
}
}).listen(3000);
curl -s -X POST localhost:3000/api/orders -H 'Content-Type: application/json' \
-d '{"productId": 7, "quantity": 2}'
# {"id":1,"productId":7,"quantity":2,"createdAt":"2026-10-03T10:00:00.000Z"}
curl -s localhost:3000/api/orders/1
curl -s -X DELETE localhost:3000/api/orders/1 # {"error":"method not allowed"} with status 405
Other request formats.
| Content-Type | Parse with |
|---|---|
application/json | JSON.parse on the collected body |
application/x-www-form-urlencoded | new URLSearchParams(bodyText) |
multipart/form-data | A streaming parser (see "Handling Uploads and Multipart Data") |
text/plain | The string itself |
What this exercise shows. Even this small server needs routing, parameter extraction, body parsing with limits, content-type checks, validation, consistent JSON responses and central error handling. Express, Fastify, Hono and similar frameworks (Chapter 12) package these concerns as routers and middleware, but they run on the same req/res objects.
Key Takeaways
- Routing maps a method and a path pattern to a handler; return 404 for unknown paths and 405 for known paths with the wrong method.
- Extract path parameters with patterns such as
/orders/:id(regular expressions orURLPattern), query parameters withURL.searchParams. - Parse bodies according to
Content-Type, enforce size limits and respond with 400, 413, 415 or 422 as appropriate. - Centralise error handling: map known errors to status codes, log unexpected ones and never leak stack traces to clients.
Practice
- (Beginner) Add
PUT /api/orders/:idandDELETE /api/orders/:idroutes to the example, returning 204 for successful deletes.- (Intermediate) Add support for
application/x-www-form-urlencodedbodies and an HTML form atGET /that posts to/api/orders. Make the router support query-based filtering such asGET /api/orders?productId=7.- (Interview) "What would you need to add to this hand-written server before running it in production, and at what point would you switch to a framework?" Hint: think about security headers, CORS, logging, validation, timeouts, graceful shutdown, testing and the cost of maintaining your own router.
Serving Static Files and Streams
Most web applications serve some static assets: HTML, CSS, JavaScript bundles, images, downloads. A static file server maps URL paths to files on disk, sends the right Content-Type, streams the content and supports caching. It is also a place where security mistakes are easy to make.
Requirements of a correct static server.
| Requirement | Why |
|---|---|
| Map URL to file safely | Prevent path traversal (/../../etc/passwd, encoded variants like %2e%2e%2f) |
Correct Content-Type | Browsers rely on it; add X-Content-Type-Options: nosniff so they do not guess |
| Stream the file | Constant memory regardless of file size and number of clients |
Content-Length and Last-Modified/ETag | Progress bars, caching and conditional requests (304) |
Cache-Control | Long caching for fingerprinted assets, revalidation for HTML (Chapter 10.1) |
HEAD and Range support | Clients checking metadata, resumable downloads, video seeking |
An implementation.
// static-server.js
import { createServer } from 'node:http';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
const ROOT = path.resolve('public');
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.woff2': 'font/woff2',
};
createServer(async (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { Allow: 'GET, HEAD' });
return res.end();
}
let pathname;
try {
pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname); // decode %2e%2e etc. first
} catch {
res.writeHead(400);
return res.end('bad request');
}
if (pathname.endsWith('/')) pathname += 'index.html';
const filePath = path.join(ROOT, pathname); // join normalises '..' segments
if (!filePath.startsWith(ROOT + path.sep)) { // still inside ROOT?
res.writeHead(403);
return res.end('forbidden');
}
let info;
try {
info = await stat(filePath);
if (!info.isFile()) throw Object.assign(new Error(), { code: 'ENOENT' });
} catch (err) {
res.writeHead(err.code === 'ENOENT' ? 404 : 500);
return res.end(err.code === 'ENOENT' ? 'not found' : 'error');
}
const etag = `W/"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`; // cheap validator from size + mtime
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, { ETag: etag }); // client's copy is still valid
return res.end();
}
res.writeHead(200, {
'Content-Type': TYPES[path.extname(filePath)] ?? 'application/octet-stream',
'Content-Length': info.size,
'Last-Modified': info.mtime.toUTCString(),
ETag: etag,
'Cache-Control': filePath.endsWith('.html') ? 'no-cache' : 'public, max-age=3600',
'X-Content-Type-Options': 'nosniff',
});
if (req.method === 'HEAD') return res.end(); // headers only
try {
await pipeline(createReadStream(filePath), res); // streams with backpressure
} catch (err) {
if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') console.error(err); // client aborts are normal
}
}).listen(3000);
Why streams here. With readFile, serving a 500 MB video to 20 clients needs about 10 GB of memory. With pipeline(createReadStream(...), res), each client uses roughly one buffer (64 KiB) at a time, and a slow client automatically slows down its own disk reads through backpressure (11.4).
Range requests. Video players and download managers ask for parts of a file with Range: bytes=1000-1999. The server answers 206 Partial Content with Content-Range: bytes 1000-1999/5000000 and streams just that part with createReadStream(file, { start: 1000, end: 1999 }) (both inclusive). Supporting it also requires Accept-Ranges: bytes and a 416 response for invalid ranges.
Compression. Text assets can be compressed on the fly (zlib.createGzip() or createBrotliCompress() in the pipeline, with Content-Encoding and Vary: Accept-Encoding), but production setups usually precompress at build time or let a reverse proxy or CDN do it.
In production. Node can serve static files well, but a CDN or a reverse proxy such as nginx is usually better at it (sendfile, caching, TLS, compression), leaving Node for dynamic requests. Frameworks provide tested static middleware (express.static, @fastify/static) that handles ranges, ETags and edge cases.
Key Takeaways
- Map URLs to files only after decoding and normalising, and verify the result stays inside the root directory.
- Send correct
Content-Type,Content-Length, caching headers andnosniff; supportHEADand conditional requests. - Stream files with
pipeline(createReadStream(file), res)for constant memory and automatic backpressure. - Range requests (
206) enable seeking and resuming; CDNs and reverse proxies usually serve static assets in production.
Practice
- (Beginner) Run the static server on a folder with an HTML page, a CSS file and an image, and check the response headers of each with
curl -I.- (Intermediate) Add
Rangesupport: parsebytes=start-end(including thestart-and-suffixforms), return 206 withContent-Range, and test seeking in an HTML<video>element.- (Interview) "How would you prevent directory traversal in a static file server?" Hint: think about the order of URL decoding and path normalisation, encoded sequences such as
%2e%2e%2f, and how to check that the final path is inside the allowed directory.
Making HTTP Client Requests
Servers are also clients: they call payment providers, other microservices, OAuth endpoints and webhooks. Node offers the low-level http.request API and, since Node 18, the global fetch, implemented by the undici library. For most code, fetch is the right choice.
fetch in Node. The same API as in browsers (Chapter 5.4), without CORS restrictions:
const res = await fetch('https://api.github.com/repos/nodejs/node', {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'my-app' },
signal: AbortSignal.timeout(5000), // always set a timeout for outgoing calls
});
if (!res.ok) { // fetch does NOT reject on 4xx/5xx
throw new Error(`GitHub API responded ${res.status}`);
}
const repo = await res.json();
console.log(repo.stargazers_count);
Sending data.
const res = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.API_TOKEN}` },
body: JSON.stringify({ productId: 7, quantity: 2 }),
signal: AbortSignal.timeout(10_000),
});
Streaming large responses. res.body is a web ReadableStream; convert it to a Node stream to pipe it to disk without buffering:
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const res = await fetch('https://example.com/large-dataset.csv');
if (!res.ok || !res.body) throw new Error(`download failed: ${res.status}`);
await pipeline(Readable.fromWeb(res.body), createWriteStream('dataset.csv'));
http.request / https.request. The original callback-and-stream API, still useful for low-level control or older code:
import https from 'node:https';
const req = https.request(
{ hostname: 'api.example.com', path: '/status', method: 'GET', timeout: 5000 },
(res) => { // res is an IncomingMessage (Readable)
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => (body += chunk));
res.on('end', () => console.log(res.statusCode, body));
},
);
req.on('timeout', () => req.destroy(new Error('request timed out')));
req.on('error', (err) => console.error('request failed:', err.message));
req.end(); // nothing is sent until end() (or write()) is called
Connection reuse. Opening a new TCP and TLS connection for every request adds one or more round trips. Both clients pool connections: fetch through undici's global dispatcher, http.request through http.Agent (keep-alive is enabled by default for the global agent since Node 19). For heavy traffic to one service, configure the pool explicitly:
import { Agent, setGlobalDispatcher } from 'undici'; // the npm package exposes the same engine's options
setGlobalDispatcher(new Agent({
connections: 50, // max sockets per origin
keepAliveTimeout: 10_000, // keep idle sockets for 10 s
}));
Resilience. Remote services fail. A robust client has:
| Technique | Purpose |
|---|---|
Timeouts (AbortSignal.timeout) | Never wait forever; a hung dependency should not hang your server |
| Retries with exponential backoff and jitter | Recover from transient errors (network blips, 502/503, 429) |
| Retry only idempotent requests | Retrying a POST can create duplicate orders, unless the API supports idempotency keys |
| Circuit breaker | Stop calling a dependency that keeps failing, and fail fast instead |
| Response validation | Never trust the shape of external data; validate it |
async function fetchWithRetry(url, options = {}, { retries = 3, baseMs = 200 } = {}) {
for (let attempt = 0; ; attempt++) {
try {
const res = await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
if (res.status < 500 && res.status !== 429) return res; // success or a non-retryable client error
if (attempt >= retries) return res;
} catch (err) {
if (attempt >= retries) throw err; // network error or timeout
}
const delay = baseMs * 2 ** attempt * (0.5 + Math.random()); // exponential backoff with jitter
await new Promise((r) => setTimeout(r, delay));
}
}
Server-side request forgery (SSRF). If your server fetches URLs supplied by users (webhooks, link previews, image imports), an attacker can make it request internal addresses such as http://169.254.169.254/ (cloud metadata) or http://localhost:6379. Validate the scheme and host against an allow-list and block private IP ranges (Chapter 17).
Key Takeaways
- Use the global
fetchfor outgoing requests; checkres.okbecause HTTP errors do not reject. - Always set timeouts with
AbortSignal.timeout; stream large bodies withReadable.fromWebandpipeline. http.requestgives low-level stream control; connection pooling and keep-alive avoid repeated handshakes.- Add retries with backoff for idempotent requests, circuit breakers for failing dependencies, and SSRF protection for user-supplied URLs.
Practice
- (Beginner) Write a script that fetches a public JSON API, handles non-2xx responses and timeouts, and prints a field from the result.
- (Intermediate) Build a small server whose
/slowendpoint randomly fails with 503 or takes 3 seconds, and a client usingfetchWithRetryagainst it. Log every attempt and its delay, and make sure the total time is bounded.- (Interview) "Your service calls a third-party API that occasionally hangs. A few minutes later your whole service stops responding. What happened and how do you prevent it?" Hint: think about requests waiting without timeouts, exhausted connection pools and sockets, and patterns such as timeouts, bulkheads and circuit breakers.
HTTPS Servers and TLS Configuration
Plain HTTP sends everything, including passwords and cookies, readable by anyone on the network path. HTTPS is HTTP over TLS (Chapter 10.1): it encrypts traffic, protects its integrity and proves the server's identity with a certificate. Node can terminate TLS itself with the https module, or sit behind a proxy that does it.
What a TLS server needs.
- A private key, kept secret on the server.
- A certificate binding the public key to one or more domain names, signed by a certificate authority (CA) that browsers trust, plus any intermediate certificates in the chain.
Local development certificates. Self-signed certificates trigger browser warnings. mkcert creates a local CA, installs it in your system's trust store and issues certificates for localhost:
mkcert -install # create and trust a local CA (once per machine)
mkcert localhost 127.0.0.1 ::1 # creates localhost+2.pem and localhost+2-key.pem
An HTTPS server.
// https-server.js
import { createServer } from 'node:https';
import { readFileSync } from 'node:fs';
const options = {
key: readFileSync('certs/localhost+2-key.pem'), // private key (sync is fine: start-up only)
cert: readFileSync('certs/localhost+2.pem'), // certificate (+ intermediates, concatenated)
minVersion: 'TLSv1.2', // refuse obsolete protocol versions
};
createServer(options, (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', // HSTS: browsers use HTTPS from now on
});
res.end('secure hello\n');
}).listen(8443, () => console.log('https://localhost:8443'));
Redirecting HTTP to HTTPS.
import { createServer } from 'node:http';
createServer((req, res) => {
const host = (req.headers.host ?? 'localhost').replace(/:\d+$/, '');
res.writeHead(308, { Location: `https://${host}:8443${req.url}` }); // 308 keeps the method and body
res.end();
}).listen(8080);
Important TLS options.
| Option | Purpose |
|---|---|
key, cert (or pfx) | Server identity |
ca | CAs to trust for client certificates, or a custom chain |
minVersion | Lowest TLS version accepted ('TLSv1.2' or 'TLSv1.3') |
ciphers | Cipher suites; Node's defaults (from OpenSSL) are sensible |
SNICallback | Choose a certificate per requested host name (several domains on one port) |
requestCert, rejectUnauthorized | Mutual TLS (mTLS): require client certificates |
ALPNProtocols | Negotiate HTTP/2 ('h2') vs HTTP/1.1 |
HTTP/2 with TLS.
import { createSecureServer } from 'node:http2';
import { readFileSync } from 'node:fs';
createSecureServer({
key: readFileSync('certs/localhost+2-key.pem'),
cert: readFileSync('certs/localhost+2.pem'),
allowHTTP1: true, // fall back for clients without HTTP/2
}, (req, res) => {
res.end(`hello over HTTP/${req.httpVersion}\n`); // compatibility API mirrors http's req/res
}).listen(8443);
Client-side TLS mistakes. Disabling verification (rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0) makes connections vulnerable to man-in-the-middle attacks and should never reach production. To trust an internal CA, provide it explicitly with the ca option or the NODE_EXTRA_CA_CERTS environment variable.
Where TLS terminates in production. Common architectures:
1. Node terminates TLS browser ==TLS==> Node (https module)
2. Reverse proxy terminates TLS browser ==TLS==> nginx / Caddy / Traefik --HTTP--> Node
3. Load balancer / CDN terminates browser ==TLS==> cloud LB or CDN --HTTP or TLS--> Node
Options 2 and 3 are the most common: certificates are renewed automatically (Let's Encrypt via Caddy, Traefik or cert-manager, or the cloud provider), TLS is offloaded, and Node serves plain HTTP on a private network. Node must then trust the proxy's X-Forwarded-Proto and X-Forwarded-For headers to know the original scheme and client IP, but only from that proxy, because clients can forge them.
Key Takeaways
- HTTPS needs a private key and a CA-signed certificate chain; use
mkcertfor trusted local certificates. https.createServer(options, handler)works likehttp.createServer; setminVersionand send HSTS.http2.createSecureServeradds HTTP/2 with an HTTP/1.1 fallback;SNICallbackand mTLS cover advanced setups.- Never disable certificate verification; in production TLS is often terminated by a proxy or load balancer in front of Node.
Practice
- (Beginner) Generate local certificates with
mkcert, run the HTTPS server and inspect the certificate and negotiated TLS version withopenssl s_client -connect localhost:8443.- (Intermediate) Run an HTTP server on port 8080 that redirects to HTTPS and an HTTPS server on 8443 that serves HSTS. Verify the redirect preserves the path and query string, and explain why 308 is used instead of 301.
- (Interview) "Should a Node.js application terminate TLS itself or behind a reverse proxy?" Hint: compare certificate management, performance, configuration of multiple services and what the application must know about the original request.
Handling Uploads and Multipart Data
Uploading files from an HTML form or with FormData in fetch sends a multipart/form-data body: several parts, each with its own headers, separated by a boundary string. Unlike JSON, uploads can be very large, so they must be parsed as a stream and written to disk or object storage without buffering the whole file in memory.
The wire format.
POST /upload HTTP/1.1
Host: localhost:3000
Content-Type: multipart/form-data; boundary=----x7Ab3
Content-Length: 1034217
------x7Ab3
Content-Disposition: form-data; name="title"
Holiday photos
------x7Ab3
Content-Disposition: form-data; name="photo"; filename="beach.jpg"
Content-Type: image/jpeg
<...binary JPEG bytes...>
------x7Ab3--
boundary=----x7Ab3 (chosen by the client, announced in Content-Type)
part 1: a text field "title"
part 2: a file field "photo" with filename and Content-Type
final boundary ends with "--"
Parsing this by hand means scanning a byte stream for a boundary that can be split across chunks, so use a tested streaming parser such as busboy (used by multer and many frameworks) or formidable.
A streaming upload endpoint with busboy.
// upload-server.js
import { createServer } from 'node:http';
import { createWriteStream } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import busboy from 'busboy';
const UPLOAD_DIR = path.resolve('uploads');
await mkdir(UPLOAD_DIR, { recursive: true });
const ALLOWED = new Set(['image/jpeg', 'image/png', 'application/pdf']);
createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/upload') {
res.writeHead(404);
return res.end();
}
let bb;
try {
bb = busboy({
headers: req.headers,
limits: { fileSize: 10 * 1024 * 1024, files: 3, fields: 10 }, // 10 MB per file, at most 3 files
});
} catch {
res.writeHead(400);
return res.end('expected multipart/form-data');
}
const fields = {};
const saved = [];
const writes = [];
let failed = null;
bb.on('field', (name, value) => { fields[name] = value; });
bb.on('file', (name, file, { filename, mimeType }) => {
if (!ALLOWED.has(mimeType)) {
file.resume(); // drain and discard this part
failed ??= { status: 415, msg: `type ${mimeType} not allowed` };
return;
}
const storedName = `${randomUUID()}${path.extname(filename).toLowerCase()}`; // never trust the client's name
const dest = path.join(UPLOAD_DIR, storedName);
file.on('limit', () => { failed ??= { status: 413, msg: 'file too large' }; });
writes.push(
pipeline(file, createWriteStream(dest)).then(async () => {
if (file.truncated) await unlink(dest); // busboy truncates at the limit: remove partial file
else saved.push({ field: name, original: filename, storedName, mimeType });
}),
);
});
bb.on('close', async () => {
await Promise.allSettled(writes); // wait until all files are on disk
if (failed) {
res.writeHead(failed.status, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: failed.msg }));
}
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ fields, files: saved }));
});
bb.on('error', (err) => {
res.writeHead(400);
res.end('malformed upload');
});
req.pipe(bb); // stream the request body into the parser
}).listen(3000);
curl -F title="Holiday" -F photo=@beach.jpg http://localhost:3000/upload
# {"fields":{"title":"Holiday"},"files":[{"field":"photo","original":"beach.jpg","storedName":"2c1f...e9.jpg","mimeType":"image/jpeg"}]}
Upload security checklist.
| Risk | Mitigation |
|---|---|
| Memory or disk exhaustion | Stream to storage; limit file size, number of files and fields; limit total request size |
| Path traversal via file names | Generate names on the server (UUIDs); never use the client's filename as a path |
| Wrong or forged types | The client's Content-Type is a claim; check magic bytes (11.4) for files you will process |
| Stored XSS | Serve user uploads from a separate domain, with Content-Disposition: attachment and nosniff |
| Malware | Scan files before making them available to other users when the risk warrants it |
| Slow uploads tying up resources | Request timeouts and limits per client |
Direct-to-storage uploads. At scale, uploads often bypass the Node server entirely: the server issues a short-lived pre-signed URL for object storage (S3, Google Cloud Storage, Azure Blob), and the browser uploads directly to it. Node only validates the request and records metadata, which keeps large transfers off the application servers.
Key Takeaways
multipart/form-databodies contain parts separated by a boundary; parse them with a streaming library such as busboy.- Stream file parts to disk or object storage; enforce limits on file size, count and fields.
- Generate server-side file names, validate types (ideally by content), and serve uploads safely.
- For large or numerous uploads, consider pre-signed URLs so clients upload directly to object storage.
Practice
- (Beginner) Create an HTML page with a form (
enctype="multipart/form-data") that uploads an image to the example server, and display the JSON response.- (Intermediate) Extend the server to verify the first bytes of each uploaded file against the declared MIME type (PNG, JPEG, PDF magic numbers) and reject mismatches, deleting any partial file.
- (Interview) "How would you design file uploads of up to 5 GB for a web application?" Hint: consider memory, timeouts, resumability, where the bytes should flow (through your server or directly to storage) and how you validate the result.
Logging, Error Handling and Debugging
When a server misbehaves in production, you cannot attach a debugger to every user's request. You rely on logs to know what happened, error handling that keeps one failure from becoming an outage, and debugging tools to reproduce and inspect problems locally.
From console.log to structured logging. console.log writes free-form text to stdout. That is fine for development, but in production logs are collected from many instances and searched by machines. Structured logs emit one JSON object per line with consistent fields:
import pino from 'pino'; // fast JSON logger, widely used with Node
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
logger.info({ orderId: 42, total: 59.8 }, 'order created');
// {"level":30,"time":1791021600000,"pid":4120,"hostname":"api-7f9c","orderId":42,"total":59.8,"msg":"order created"}
logger.error({ err }, 'payment failed'); // err is serialised with message and stack
logger.debug('cache miss'); // not printed at level 'info'
| Level | Use for |
|---|---|
fatal | The process is about to exit |
error | A failed operation that needs attention |
warn | Something unexpected but handled (a retry, a deprecated call) |
info | Important business and life-cycle events (started, order created) |
debug / trace | Detailed diagnostics, normally disabled in production |
Logging rules. Log to stdout and let the platform (Docker, Kubernetes, systemd, a log agent) collect and ship the output. Never log secrets, tokens, passwords or full card numbers (use the logger's redaction options). Include context that makes entries searchable: request ID, user ID, route, duration, status.
Request IDs with AsyncLocalStorage. To correlate all log lines from one request, assign an ID and make it available everywhere without passing it through every function. AsyncLocalStorage keeps a value associated with the current asynchronous execution context:
import { AsyncLocalStorage } from 'node:async_hooks';
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import pino from 'pino';
const baseLogger = pino();
const als = new AsyncLocalStorage();
export const log = () => als.getStore()?.logger ?? baseLogger; // any module can call log()
createServer((req, res) => {
const requestId = req.headers['x-request-id'] ?? randomUUID();
const logger = baseLogger.child({ requestId, method: req.method, url: req.url });
const start = performance.now();
als.run({ logger }, async () => { // everything awaited inside sees this store
res.setHeader('X-Request-Id', requestId);
res.on('finish', () => {
log().info({ status: res.statusCode, ms: Math.round(performance.now() - start) }, 'request completed');
});
await handle(req, res); // deep inside, log().info(...) includes requestId
});
}).listen(3000);
Error-handling layers. Errors should be handled at the narrowest level that can do something useful, with safety nets above:
1. operation level try/catch around an await that can fail and has a fallback (cache miss -> DB)
2. request level one catch per request: map to 4xx/5xx, log, respond (see the router example)
3. emitter level 'error' listeners on streams, sockets, servers, child processes (11.4)
4. process level 'uncaughtException' / 'unhandledRejection': log as fatal and exit (11.5)
5. supervisor level systemd, Kubernetes or PM2 restarts the process
Distinguish operational errors (expected failures: invalid input, a file not found, a timed-out dependency), which are handled and reported, from programmer errors (bugs: reading a property of undefined), which should be logged loudly and fixed. Never send stack traces or internal messages to clients in production.
Debugging with the inspector. Node includes the V8 inspector protocol, which Chrome DevTools and VS Code speak:
node --inspect server.js # debugger listens on 127.0.0.1:9229
node --inspect-brk server.js # also pause on the first line (for start-up problems)
node --inspect-wait server.js # wait for a debugger to attach before running
Open chrome://inspect in Chrome (or use VS Code's "JavaScript Debug Terminal" or a launch configuration) to set breakpoints, step through code, inspect variables and take CPU profiles and heap snapshots. Never expose the inspector port publicly: it grants full code execution. A debugger; statement in code acts as a breakpoint when a debugger is attached.
Other diagnostic tools.
| Tool | Use |
|---|---|
--enable-source-maps | Stack traces point to TypeScript or original source lines |
--trace-warnings, --trace-deprecation | Stack traces for warnings and deprecations |
NODE_DEBUG=http,net | Internal debug output from core modules |
--heapsnapshot-signal=SIGUSR2, --heapsnapshot-near-heap-limit=1 | Heap snapshots on demand or before an out-of-memory crash, for leak analysis |
--report-on-fatalerror, process.report.writeReport() | Diagnostic report: stacks, resource usage, environment |
--cpu-prof | CPU profile for performance work (11.3) |
diagnostics_channel | Subscribe to internal events (for example http.client.request.start) for tracing |
| OpenTelemetry | Distributed tracing, metrics and logs across services |
Key Takeaways
- Use structured JSON logs with levels, context fields and redaction; write them to stdout for the platform to collect.
AsyncLocalStoragepropagates per-request context such as a request ID through asynchronous code.- Handle errors in layers: operation, request, emitter, process and supervisor; keep stack traces away from clients.
- Debug with
--inspectand Chrome DevTools or VS Code; use source maps, heap snapshots, diagnostic reports and tracing for deeper problems.
Practice
- (Beginner) Add pino to a small server, log one line per request with method, URL, status and duration, and change the level with an environment variable.
- (Intermediate) Use
AsyncLocalStorageso that a log call inside a deeply nested async function (called from a request handler through two other modules) automatically includes the request ID. Send concurrent requests and verify that IDs never mix.- (Interview) "A Node.js service's memory grows steadily until it is killed every few hours. How do you find the cause?" Hint: consider heap snapshots taken at different times and compared, typical leak sources (caches without limits, listeners never removed, closures captured by timers) and how to reproduce under load.
<a id="12-middleware-and-server-frameworks-with-express"></a>