Node runs JavaScript outside a browser, on one thread, with a loop that hands slow work to the operating system and comes back for the answer. Almost every mysterious Node bug traces to one of three things: something occupied that single thread, an error crossed an asynchronous boundary where try could not follow, or a file resolved by rules nobody had read. This is the shape of the runtime — what runs when, what stalls, how failure travels, and why the two module systems disagree.
Each chapter opens with the short version. Tap one to read the detail.
One thread, and what it costs
~2 min
Your JavaScript runs on one thread, so anything synchronous delays every pending request and connection. Node also ships globals you never import — and names that only look global.
Node is a JavaScript engine wrapped in an event loop and an input/output layer. Your code gets one thread. Slow file and network work is handed off and continues elsewhere, but every line you write — parsing, hashing, a loop over an array — occupies that thread while it runs. Measure it:
const t = Date.now();
setTimeout(() => console.log('timer late by', Date.now() - t - 10, 'ms'), 10);
while (Date.now() - t < 500) {} // half a second of synchronous work
// prints: timer late by 490 ms
In a server that half second is every connected user waiting. The cost model is occupancy, not throughput.
Second surprise is what counts as global. fetch, URL, AbortController, structuredClone and performance need no import. But __dirname, require, module and exports are not globals — the documentation is explicit that they only appear to be. They are parameters of the wrapper Node puts around every CommonJS file, which is why they vanish in ES modules.
Prefix builtins with node:: it names the builtin unambiguously, where a bare fs can be redirected through the module cache. And learn one standard-library default now: console.log renders objects two levels deep, then prints [Object], quietly dropping data from logs.
Rule: treat the main thread as a shared resource — work that is not waiting on something is charging every other user for its time.
The loop: phase order, and the queues that jump it
~2 min
Each turn runs a fixed sequence — timers, poll for I/O, then check for setImmediate. Two queues jump all of it: process.nextTick and promise microtasks, drained to empty before the loop moves on.
The order is fixed: timers, pending callbacks, poll (where new I/O arrives), check (where setImmediate runs), then close handlers. With nothing due, the loop parks in poll waiting on the operating system, which is why an idle server costs nothing.
That order settles a common argument:
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// inside an I/O callback: 'immediate' first, every single time
That callback already runs in poll, so check comes next and timers only after wrapping around. Move the same two lines to the top of the file and the order is genuinely nondeterministic — ten runs here gave immediate first nine times and timeout once. Never encode either ordering in logic.
process.nextTick is not a phase at all. Its queue drains after the current operation whatever phase you are in, ahead of promise microtasks — so a nextTick callback that re-queues itself starves I/O forever while looking busy.
Timer delays are approximate, not appointments. The documentation guarantees nothing about exact timing, and it means it in both directions: of 3000 timers measured here, 58 fired early, by up to 0.9 ms.
Cancellation rides the same machinery: an AbortController owns one signal, canceling rejects with an error named AbortError and code ABORT_ERR — expected, not a failure to log — and that signal is one-shot, so a retry needs a fresh controller.
Rule: use setImmediate to yield to I/O, and nextTick only to defer inside the current operation — never for repeating work.
Launching, configuring and stopping a process
~2 min
Your arguments start at index 2, every value in process.env is a string (so "false" is truthy), and runtime flags only work before the entry point. Prefer process.exitCode to process.exit().
process.argv[0] is the node binary and argv[1] the script, so your own arguments start at index 2. util.parseArgs reads options, short flags and positionals without a dependency.
Everything in process.env is a string. Assign a boolean and you get its text back — which is how the most common configuration bug in Node turns a feature permanently on:
Flag position is the silent failure. Runtime flags belong before the entry point; after it they become your script's arguments and node ignores them. Here node --max-old-space-size=256 heap.js reported a heap limit of 304 megabytes, while node heap.js --max-old-space-size=256 reported 4144 — the same flag doing nothing, with no warning.
Shutdown is where output disappears. process.exit() stops everything at once, and writes to standard output can be asynchronous: piping 200,000 lines to a slow reader and calling exit() delivered 648 of them, where process.exitCode and a drained loop delivered all 200,000. SIGTERM and SIGINT can be trapped for graceful shutdown; SIGKILL cannot be caught at all.
Rule: set process.exitCode and let the loop finish; call process.exit() only when you have accepted losing whatever is queued.
CommonJS: the cache, the wrapper, and cycles
~2 min
require evaluates a file on the spot, blocking the loop, then caches it by resolved path so the body runs once. The classic bug is reassigning exports, which rebinds a local name and exports nothing.
Every CommonJS file is wrapped in a function receiving exports, require, module, __filename, __dirname. exports starts as a reference to module.exports and nothing more, so reassigning it merely renames a local variable:
require is synchronous: it reads, compiles and evaluates the file right there. Results are cached by resolved filename, so the body runs once and every later require of that path returns the identical object — a feature and a hazard at once, because module-level state is shared by every consumer.
Circular requires do not error, which is worse than if they did. The second file gets whatever the first has exported so far: if a.js requires b.js, which requires a.js back, b.js sees a.done === false because a.js has not reached its last line. Nothing warns you; a value is simply undefined at import time and correct a moment later.
A bare specifier walks node_modules upward from the requiring file, not from the working directory, which is why one name can resolve to different files in two places.
Rule: assign to module.exports, never to exports, and treat a cached module's state as global state.
ES modules: exact specifiers, and the CommonJS seam
~2 min
The resolver never guesses: extensions are mandatory and a directory has no implicit index. Importing CommonJS works, but its named exports come from a static scan, so anything assigned dynamically hides behind the default.
A file is an ES module if it ends in .mjs, or sits under a package.json with "type": "module"; .cjs opts back out. With no marker, Node inspects the source for module syntax and decides.
The resolver does no guessing, and each refusal names itself:
import { x } from './dep'; // ERR_MODULE_NOT_FOUND — "Did you mean ./dep.js?"
import { y } from './sub'; // ERR_UNSUPPORTED_DIR_IMPORT — no implicit index
import { x } from './dep.js'; // works
__dirname and __filename do not exist here; referencing one is a ReferenceError that says so. Use import.meta.url, or import.meta.dirname where your version has it.
The seam that costs hours is importing CommonJS. Node runs a heuristic static analysis over the source for a best-effort list of named exports, and the documentation is explicit that it is a snapshot, not a live binding. A property attached under a computed key is therefore invisible: given exports.declared = 1 and module.exports[key] = 2, the namespace holds declared and the default, and import { computed } is a SyntaxError. The whole object is always reachable as the default import — that is the escape hatch.
Between real ES modules, bindings are live: an imported name tracks the exporter's later reassignment, where a required value froze when you took it.
Rule: write the extension every time, and reach for the default import the moment a named import from CommonJS comes back missing.
Failure: what try cannot catch
~2 min
A try block has already returned by the time an asynchronous callback throws, so the error escapes. Match on err.code, never message text — and an unlistened 'error' event crashes the process.
try {
fs.readFile('/no/such/file', (err, data) => { if (err) throw err; });
} catch (e) {
console.log('caught'); // never runs
}
// prints "the try block already returned", then crashes the process
The callback runs later, on a different stack: the documentation says errors raised that way cannot be intercepted with try/catch, because they are thrown after the calling code has exited. An await fixes it — it preserves the calling frames, so a surrounding try works and the trace is readable.
Identify errors by code. Message text may change in any version; error.code is the stable contract. A failed system call carries code, errno, syscall and path together — a missing file gives code: 'ENOENT', syscall: 'open'. An unhandled promise rejection terminates the process with a non-zero exit code.
Emitters carry their own rule. Listeners run synchronously in registration order, but emitting 'error' with no listener attached throws and crashes the process — the single most common way a Node service dies. Removing a listener needs the same function reference, so an inline arrow can never be detached; that is the classic leak, and the warning at the eleventh listener is advice, not a limit.
Rule: attach an 'error' listener to every emitter you keep alive, and branch on err.code.
Bytes, chunks and backpressure
~2 min
A stream hands you arbitrary byte chunks, never records — a multi-byte character can split across two. write() returning false means stop until 'drain', and a failing pipe() leaves its destination open where pipeline() does not.
A Buffer is a Uint8Array subclass holding raw bytes, and byte count is not character count. Chunk boundaries fall wherever they fall:
const b = Buffer.from('héllo'); // 5 characters, 6 bytes
b.subarray(0, 2) + b.subarray(2) // "h��llo" — é was cut in half
// node:string_decoder holds the partial character back until the next chunk
Two more buffer facts: subarray is a view, so writing through it mutates the parent, and Buffer.allocUnsafe skips zero-filling, so its contents are documented as unknown and possibly sensitive.
Backpressure keeps memory flat. write() returns false once buffered data passes highWaterMark, and you stop until 'drain' fires. Ignore it and you buffer the whole source in memory.
The default everyone gets wrong is error handling. When the source of a pipe() fails, the destination is not closed — the documentation says so, and it is a leaked descriptor. Run the two side by side and a failing pipe() leaves the destination undestroyed, while pipeline() reports the first error and tears down every stage.
for await (const chunk of readable) applies backpressure automatically and is the shortest correct consumer. A readable is one-shot: a second pass gets nothing. Separately, fetch returns a web ReadableStream, whose getReader() locks it so a second reader throws; Readable.fromWeb bridges it back.
Rule: use pipeline, never a bare pipe — and never assume a chunk is a whole anything.
Files and paths
~2 min
readFile loads the whole file into memory and returns a Buffer unless you ask for an encoding. Path functions never touch the disk, and join will normalize .. straight out of your directory.
Three flavors of the same operations: callbacks, the *Sync forms, and promises under node:fs/promises. The synchronous ones block the one thread, so they belong in scripts, never in a request handler.
readFile holds the entire file in memory, so stream anything large. Without an encoding option every read returns a Buffer rather than a string. And a file handle from open must be closed explicitly, including on the error path — the documentation warns against relying on the automatic fallback.
Path handling is where security bugs live, because none of these functions consult the filesystem. They are string operations:
resolve walks right to left until it has an absolute path; join concatenates and then normalizes, which is precisely what lets .. escape. The safe containment test is path.relative(base, target), checking the result does not start with ...
Two more that save an afternoon: mkdir with { recursive: true } creates missing parents without failing on an existing one, and lstat describes a symlink where stat follows it.
Rule: never build a path from input you did not write without a path.relative containment check.
HTTP servers: nothing is parsed for you
~2 min
node:http handles message framing and no more — the body arrives as chunks you consume yourself, header names come back lower-cased, and a response you never end holds its socket until a timeout.
The request is a readable stream and the response a writable one. There is no body parsing, no form decoding, nothing:
const server = http.createServer(async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk; // nothing does this for you
console.log(Object.keys(req.headers)); // ['x-trace-id'] — lower-cased
res.end('ok');
});
server.listen(0, '127.0.0.1'); // port 0: ask the system for any free port
Consequences follow. Ignoring a request body still means consuming or destroying it before the connection can be reused. Headers go out with the first chunk, so writeHead after res.write fails with ERR_HTTP_HEADERS_SENT. A response never ended holds its socket until a timeout fires — which is why headersTimeout, requestTimeout and keepAliveTimeout all exist, each guarding a different stall.
If you set Content-Length yourself, count with Buffer.byteLength, not string length; the two disagree the moment a character needs more than one byte. Outbound, connection reuse belongs to the Agent, whose socket limit is the real cap on your concurrency, and a fetch response body can be read only once.
Underneath, a socket is a duplex stream, so every backpressure rule above applies unchanged, and it carries bytes rather than messages: framing is your job.
Rule: consume or destroy every request body, and end every response.
HTTP/2: one connection, many streams
~1 min
node:http2 is a different shape, not a faster node:http. One session multiplexes many streams, the request line becomes pseudo-headers, and a Compatibility API exists so one app can serve both protocols.
An Http2Session is the connection; every exchange is an Http2Stream on it. The request line is gone — :method, :path, :scheme and :authority arrive as headers:
server.on('stream', (stream, headers) => {
console.log(Object.keys(headers).filter((k) => k.startsWith(':')));
// [':path', ':method', ':authority', ':scheme']
console.log(headers[':authority'], headers['host']);
// '127.0.0.1:58620' undefined <- :authority is where host went
});
That is the misconception worth naming: code that reads req.headers.host finds nothing. The Compatibility API exists for exactly this — it reproduces HTTP/1's public surface so one application serves both — but it targets the public API only, so a library reaching into HTTP/1 internals is explicitly unsupported.
Two more shapes differ. Canceling one exchange is RST_STREAM; retiring the whole connection is GOAWAY, which names a last stream id so in-flight work can finish. And header compression is shared across streams, so anything secret is marked with http2.sensitiveHeaders to keep it out of that table.
Rule: reach for node:http2 when you want multiplexing, and read the pseudo-headers rather than the HTTP/1 ones.
Getting work off the one thread
~2 min
Threads help with computation only — I/O is already concurrent, so a worker adds nothing there. Messages are copied by structured clone, which silently drops functions, getters and class identity.
The documentation is blunt: workers are for CPU-intensive JavaScript and do not help much with I/O-intensive work, because the built-in asynchronous I/O is already more efficient than a thread would be. Reach for one when profiling shows computation on the loop, never because something merely feels slow.
Workers do not share memory. postMessage copies by the structured clone algorithm, and what does not survive is the interesting part:
class Job { constructor(n) { this.n = n; } get double() { return this.n * 2; } }
worker.postMessage(new Job(21));
// the worker receives { n: 21 } — a plain Object; getter and class identity gone
An ArrayBuffer can be transferred instead of copied, which detaches it in the sender: its length becomes zero on your side. Starting a worker is not free, so pool them, and an uncaught error inside one arrives as an 'error' event on the parent's handle.
Separate processes are the other axis. spawn streams output; exec buffers it, and past maxBuffer — 1048576 bytes by default — the child is killed with ERR_CHILD_PROCESS_STDIO_MAXBUFFER. exec also runs a shell, so interpolated input is command injection: passing hello; echo INJECTED ran both commands, where execFile('echo', [input]) passed the whole string through as one harmless argument.
Rule: reach for a worker only for computation, and never build a command string out of input you did not write.
Secrets, and shrinking the blast radius
~2 min
Hashing is one-way, so passwords need a work-factored derivation with a unique salt, not a bare digest. Compare secrets with timingSafeEqual — which throws rather than returning false on a length mismatch.
A digest is one-way: there is no decrypt for a hash. That also makes a plain sha256 the wrong tool for passwords, because it is fast and deterministic — the same password gives the same digest every time, which is exactly what precomputed tables exploit. scrypt is documented as a password-based key derivation function; give every password its own random salt, which need not be secret, and identical passwords land on different results.
Comparison has a trap of its own:
timingSafeEqual(token, Buffer.from('secret-token')) // true
timingSafeEqual(token, Buffer.from('wrong--token')) // false
timingSafeEqual(token, Buffer.from('short')) // throws
// ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH: Input buffers must have the same byte length
Comparing secrets with === leaks the matching prefix through timing; timingSafeEqual is the constant-time fix, but it refuses unequal lengths, so a token check must handle that throw. Elsewhere, randomBytes and randomUUID are cryptographically random where Math.random is not.
Node also ships a permission model. --permission denies filesystem, network, child-process, worker and native-addon access until granted; a denied call raises ERR_ACCESS_DENIED. It binds your dependencies too, which is the point — but the documentation is explicit that this is a seat belt, not a sandbox, with no guarantee against deliberately malicious code.
Rule: salt and work-factor every password hash, compare secrets in constant time, and read --permission as damage limitation rather than isolation.
Measuring: the clock, and the loop
~2 min
Use the monotonic clock for durations — a wall-clock difference can be adjusted underneath you. The number that predicts user-visible latency is event-loop delay, reported as a histogram in nanoseconds.
performance.now() returns a high-resolution timestamp where zero is the start of this process, and it does not move backwards. Subtracting two Date.now() readings measures wall-clock time instead, which the system can adjust while you are measuring. performance.mark() and performance.measure() name the moments and the span between them.
The most useful number in a Node service is how long the loop is kept from its work. A saturated loop is what users experience as latency, and it hides completely inside an average:
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
// ... one handler blocks for 200 ms ...
h.disable();
h.max / 1e6 // 204.7 — milliseconds, from a nanosecond histogram
h.percentile(99) / 1e6
Values are in nanoseconds, which is the first thing people get wrong, by three orders of magnitude. The histogram exposes min, max, mean, stddev and arbitrary percentiles, so you can watch the tail rather than the mean — in that run one blocking handler showed as a maximum of 204.7 ms against a mean of 21.6, which is the shape to look for.
performance.eventLoopUtilization() is the companion measure: the share of time the loop spent working rather than waiting.
Rule: measure durations with the monotonic clock and health with loop-delay percentiles, because an average hides the request that actually hurt.
Running TypeScript, and testing, with nothing installed
~2 min
Node runs a .ts file by erasing the annotations — it never type-checks, so a real checker still belongs in your pipeline. In node --test, a subtest you forget to await is canceled and counted as a failure.
Type stripping replaces TypeScript syntax with whitespace and runs the result. It does not downlevel syntax, does not read tsconfig.json, and — the part that surprises people — does not check anything:
const port: number = '3000'; // node has no complaint
console.log(port.toFixed(0)); // TypeError: port.toFixed is not a function
Running is not checking. Anything that would need real emitted code is rejected rather than ignored: an enum fails with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, as do namespaces containing runtime code, parameter properties and decorators. Import specifiers need their file extension, and a type imported without the type keyword becomes a value import that fails at runtime. This became on by default in the 22.18 release and stable later; earlier versions need a flag.
The test runner is stable and needs nothing installed. node --test discovers files by name, runs each in its own process by default, and node:assert/strict makes equality deep and type-sensitive. The trap is structural:
test('parent', async (t) => {
t.test('subtest', async () => { /* would have passed */ }); // not awaited
});
// ✖ cancelled: 'test did not finish before its parent and was cancelled'
It counts as a failure rather than being silently skipped, but only the summary tells you. mock.timers also advances setTimeout instead of sleeping through it.
Rule: keep a real type-check in your pipeline, and await every subtest.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.