Node.js — Engineering
Notes for Server Code
A working reference for Node as it runs in production — the event loop and why it matters, streams and backpressure, the module systems, and the operational concerns that keep a service alive under load.
Node runs your JavaScript on a single thread. It scales not by threads-per-request but by never blocking that thread — every I/O call hands off to the OS (via libuv) and your code continues. The model only breaks when you do CPU-heavy work synchronously and starve the loop.
$ node app.js $ node --watch app.js # built-in reloader $ node --env-file=.env app.js # load env (20+)
JSON.parse on a 50 MB string, or fs.readFileSync in a request handler freezes every connection until it finishes. Offload CPU work to a worker (Ch. 15).// CommonJS (.cjs, or .js with no type:module) const fs = require("node:fs"); module.exports = { fn }; // ES Modules (.mjs, or "type":"module" in package.json) import fs from "node:fs"; export { fn }; const data = await import("./x.js"); // dynamic
node: (node:fs, node:path) — it's unambiguous and can't be shadowed by a malicious npm package of the same name.Each loop tick passes through phases: timers (setTimeout), pending callbacks, poll (I/O), check (setImmediate), close. Between every phase Node drains the microtask queue: process.nextTick first, then resolved Promises.
setTimeout(() => log("timeout"), 0); setImmediate(() => log("immediate")); Promise.resolve().then(() => log("promise")); process.nextTick(() => log("nextTick")); // nextTick → promise → (timeout|immediate, order varies)
process.nextTick can starve the loop entirely — microtasks run to exhaustion before I/O. Prefer setImmediate for "yield then continue".import { EventEmitter } from "node:events"; class Bus extends EventEmitter {} const bus = new Bus(); bus.on("job", (j) => handle(j)); bus.once("ready", init); bus.emit("job", { id: 1 });
"error" event with no listener throws and crashes the process. Always attach an error listener to emitters and streams.import { setTimeout as sleep } from "node:timers/promises"; import { readFile } from "node:fs/promises"; // parallel — start all, then await const [a, b] = await Promise.all([ readFile("a.txt"), readFile("b.txt"), ]); // tolerate partial failure const res = await Promise.allSettled(tasks);
await in a loop is the #1 Node performance bug. If the iterations are independent, build the promise array and Promise.all it (bound concurrency with a pool if N is large).import { pipeline } from "node:stream/promises"; import { createReadStream, createWriteStream } from "node:fs"; import { createGzip } from "node:zlib"; await pipeline( createReadStream("big.log"), createGzip(), createWriteStream("big.log.gz"), ); // handles backpressure + cleanup on error
.pipe() in production — it doesn't forward errors and leaks file descriptors on failure. pipeline() does both correctly.Buffer.from("hello", "utf8"); Buffer.alloc(16); // zero-filled (safe) // Buffer.allocUnsafe is faster but uninitialised — never expose buf.toString("hex"); Buffer.concat([a, b]);
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"; await mkdir("out", { recursive: true }); const txt = await readFile("a.txt", "utf8"); for (const name of await readdir(".")) log(name);
readFile can OOM the process.import path from "node:path"; import os from "node:os"; path.join(__dirname, "data", "f.json"); // OS-correct sep path.resolve("./rel"); import.meta.dirname; // ESM equiv of __dirname (20.11+) os.cpus().length; os.totalmem();
import { createServer } from "node:http"; const server = createServer((req, res) => { if (req.url === "/health") { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ ok: true })); } res.writeHead(404).end(); }); server.listen(3000);
const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 5000); try { const r = await fetch(url, { signal: ac.signal }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.json(); } finally { clearTimeout(t); }
fetch has no default timeout. A hung upstream will hold the request forever and exhaust your connection pool — always wire an AbortController deadline.process.on("unhandledRejection", (err) => { logFatal(err); process.exit(1); // let the supervisor restart a clean process }); process.on("uncaughtException", (err) => { logFatal(err); process.exit(1); });
uncaughtException and keep serving — the process is in an unknown state. Log, flush, exit; let your process manager (systemd, PM2, k8s) restart it fresh.const port = process.env.PORT ?? 3000; function shutdown() { server.close(() => { db.end(); process.exit(0); }); setTimeout(() => process.exit(1), 10_000).unref(); } process.on("SIGTERM", shutdown); // what k8s/docker send process.on("SIGINT", shutdown); // Ctrl-C
import { spawn } from "node:child_process"; const p = spawn("ffmpeg", ["-i", src, out]); p.stdout.on("data", d => log(d.toString())); p.on("exit", code => log("done", code));
exec (it runs a shell → command injection). Use spawn/execFile with an args array — no shell, no injection.import { Worker } from "node:worker_threads"; function heavy(data) { return new Promise((res, rej) => { const w = new Worker("./crunch.js", { workerData: data }); w.on("message", res); w.on("error", rej); }); }
A single Node process uses one core. To use a machine's N cores, run N processes behind a load balancer — via the cluster module, a process manager (PM2), or (best in modern infra) N container replicas fronted by your orchestrator. Keep processes stateless; push session/cache to Redis.
cluster module — the orchestrator already handles restart, health and balancing, and per-container observability is cleaner.{
"type": "module",
"engines": { "node": ">=20" },
"scripts": { "start": "node app.js", "test": "node --test" }
}npm ci (not npm install) in CI/CD — ci installs exactly the lockfile, deterministically, and fails if package.json and the lock disagree.import { test } from "node:test"; import assert from "node:assert/strict"; test("adds", () => { assert.equal(add(2, 3), 5); }); // run: node --test
$ node --inspect app.js # chrome://inspect $ node --prof app.js # V8 profile → isolate-*.log $ node --heap-prof app.js # heap profile for leaks
Map/array used as a cache with no eviction, or listeners added without removeListener.- Run behind a process supervisor that restarts on crash (systemd / k8s / PM2)
- Handle
SIGTERMfor graceful shutdown — drain connections before exit - Every outbound call has a timeout and a retry budget
- Structured JSON logs to stdout; never log secrets or PII
- A real
/health(process up) and/ready(deps reachable) endpoint - Pin Node via
enginesand the base image tag; rebuild on security releases - Run as a non-root user; drop capabilities in the container
| Flag | Use |
|---|---|
--watch | auto-restart on file change (dev) |
--env-file=.env | load env without dotenv (20+) |
--test | run the built-in test runner |
--inspect | attach Chrome DevTools |
--prof | V8 CPU profile |
--max-old-space-size=N | raise the heap limit (MB) |