Everything the adaptive question bank can teach and test in Node.js, from foundations through advanced practice. Work through it in order, or start practicing and let the questions find your level.
V8 implements ECMAScript itself, promises included, but the language has no files, sockets or timers. Node's C++ layer connects those to libuv, which waits on the operating system's event-notification mechanism: epoll on Linux, kqueue on macOS, I/O completion ports (IOCP) on Windows. Run node -p process.versions to see which v8 and uv releases your build bundles.
The pool serves async fs calls other than watchers, async zlib, crypto such as pbkdf2 and scrypt, and dns.lookup, but not dns.resolve*, which queries the network directly. Sockets never use it. Because the pool is shared, five slow file reads can delay the lookup behind an http.get. Set UV_THREADPOOL_SIZE in the environment before launch; assigning it inside the script is not guaranteed to work.
Only one callback runs at a time, so a 200 ms loop delays every request that arrived meanwhile by up to 200 ms, on every route, not just the slow one. A regex with catastrophic backtracking or JSON.parse on a huge body does the same, and an attacker can trigger it on purpose. Keep each callback short: use async APIs, split work into chunks, or move CPU-heavy code to worker_threads.
Feature checks need care. typeof window === 'undefined' still tells Node from a browser page, though web workers lack window too, and navigator has been a global since Node 21, so sniffing for it no longer proves you are in a browser. The older name global still works but is marked Legacy, while globalThis resolves the same way in browsers, web workers and Node.
A file's top-level var, let and const stay private because the module wrapper scopes them, so sharing happens only when you write to globalThis, on purpose or by accident. CommonJS runs in sloppy mode unless you opt in, so assigning to an undeclared name silently creates a global, while an ES module is strict and throws ReferenceError. Two packages that pick the same global name overwrite each other.
For long-standing names, bare require('fs') already returns the builtin even when node_modules/fs exists, and node: also skips any require.cache entry. The prefix is mandatory for newer builtins whose bare names were taken on npm first: node:test, node:sqlite and node:sea. A bare require('test') loads whatever package is installed under that name.
They are the same WHATWG classes browsers ship, so code using them ports in both directions. They arrived at different times: URL became global in Node 10, TextEncoder in 11, AbortController in 15 and structuredClone in 17, while fetch dropped its flag in 18 and its experimental label in 21. A library that still supports Node 16 cannot assume fetch exists.
B. The event loop, timers and scheduling
Idle and prepare, used only internally, sit just before poll. Since Node 20.3 (libuv 1.45) an iteration runs pending callbacks, poll, check and close handlers and ends with timers, after one extra timers pass before the first iteration; the cycle is the same, only its starting point moved. process.nextTick and promise callbacks belong to no phase: they run after each individual callback.
The wait is computed, not fixed: libuv blocks until the nearest timer is due, returns at once when a setImmediate is queued, and lets the process exit instead of waiting when no timer, socket, server or immediate remains. A setInterval with a 1 ms delay therefore wakes the loop constantly, while a server with open sockets and no traffic stays parked in the kernel.
An I/O callback runs in poll, and check comes before the loop wraps around to timers, so the immediate is guaranteed to run first. From the main module, whether the timer's 1 ms threshold (a 0 delay becomes 1) has passed by the first timers pass depends on process speed, so the order changes between runs. Use setImmediate for 'after this I/O, before timers', and never test the main-module order.
After each callback, Node drains the whole tick queue, then the whole microtask queue, and repeats until both are empty. The order flips when the scheduling code is itself running as a microtask, as at the top level of an ES module or after an await: there, promise callbacks run before ticks. Node 22.7 marks process.nextTick Legacy and recommends queueMicrotask.
Each recursive call returns before the next one runs, so the stack never grows and no RangeError warns you, yet the loop can never reach poll, and timers and sockets wait indefinitely. A promise chain that keeps queuing then callbacks or queueMicrotask jobs starves the loop the same way. To yield between batches of work, schedule the next batch with setImmediate, which lets I/O run in between.
Since Node 11, the drain happens after every single timer, immediate and I/O callback, matching browsers, so two timers that are due together each see their own promise callbacks run before the next timer starts. A microtask queued during the drain joins the same drain, so a long promise chain completes before any other callback gets a turn.
Timers are checked only between callbacks, and nothing interrupts running JavaScript, so a 300 ms synchronous loop makes a 100 ms timer fire at about 300 ms. setInterval does not catch up on ticks it missed; it resumes its rhythm from the late call. For real elapsed time, measure with performance.now() inside the callback instead of trusting the requested delay.
2147483647 ms is the largest signed 32-bit value, about 24.8 days, so a timer meant to fire in 30 days fires almost immediately instead, with a TimeoutOverflowWarning in Node 24. NaN also becomes 1, and fractional delays are truncated to whole milliseconds. For long schedules, store the target time and re-arm a shorter timer that checks it.
Node exits once no referenced handle or pending operation remains, and a pending promise counts as neither: a promise nobody will ever resolve lets the process exit with code 0, and an unsettled top-level await in an ES module exits with 13. Call .unref() on a heartbeat interval or metrics server so it never holds a CLI open; .ref() undoes it and timeout.hasRef() reports the state.
Its signature is setTimeout(delay, value, options), delay first and no callback, and importing it under that name shadows the global setTimeout in the file. Aborting rejects with an AbortError whose code is ABORT_ERR, so a try/catch around the await should tell cancellation apart from real failures. AbortSignal.timeout(ms) gives you a signal that aborts by itself.
C. The process: arguments, environment and lifecycle
Both entries are absolute paths, and argv[0] is process.execPath, which can differ from the command you typed when node is reached through a symlink; process.argv0 keeps the original. With node -e or node -p there is no script, so your own arguments begin at index 1. util.parseArgs defaults to process.argv with those leading entries already removed.
It has been stable since Node 20. Strict mode is the default, so an unknown flag throws ERR_PARSE_ARGS_UNKNOWN_OPTION and a bare positional throws unless you set allowPositionals: true. Option types are only boolean and string, so --port 80 arrives as the string '80' and needs converting. Everything after -- lands in positionals, even text that looks like an option.
A non-string is converted on write, so process.env.N = 5 reads back as '5'; only assigning something other than a string, number or boolean is deprecated (DEP0104). A variable that was never set is undefined, not an empty string, so parse deliberately, as in process.env.DEBUG === 'true'. On Windows the names are case-insensitive, and a worker thread gets its own copy unless it is created with SHARE_ENV.
An uncaught exception exits with 1, and so does an unhandled rejection under the default throw mode. A death by signal reports 128 plus the signal number, so a SIGTERM with no handler shows as 143, and a top-level await that never settles exits with 13. Shells, CI runners and orchestrators read only this number, so a script that logs an error and then exits 0 reports success.
When stdout is a pipe on Linux or macOS, writes are asynchronous, so a large console.log may still be queued when process.exit() runs, and only what fit in the pipe buffer arrives. The same code writing to a terminal or a file looks fine, which is why the bug appears only under | tee or a log shipper reading a pipe. 'exit' listeners still run; queued writes are dropped.
Setting process.exitCode = 1 records the failure and lets pending writes, timers and 'beforeExit' work finish, after which the process exits on its own with that code. An explicit process.exit(n) still overrides it. The catch is that anything holding the loop open, such as an active interval or a listening server, now keeps the process alive, so close those resources too.
On Linux and macOS, SIGTERM and SIGINT have default handlers that exit with 128 plus the signal number, and adding a listener removes that default: your handler must close servers and exit itself, or the process keeps running. SIGKILL and SIGSTOP refuse listeners, and trying throws EINVAL. Worker threads get no signal events, and Windows never delivers a real SIGTERM.
'beforeExit' fires every time the loop runs dry, so work scheduled from it fires it again later, and scheduling unconditionally never lets the process end. It never fires for process.exit() or an uncaught exception. 'exit' fires for process.exit() and for a drained loop, but the process ends as soon as its listeners return, so a timer, promise or async write started there is dropped.
That is the POSIX behavior. Windows reverses two cases, making terminal writes asynchronous and pipe writes synchronous, while files are synchronous everywhere. A synchronous write blocks the loop until it completes, so heavy logging to a slow terminal can stall a server, and an asynchronous write can be lost if the process exits first. process.stdout.isTTY is true only for a terminal.
A relative path passed to fs resolves against process.cwd(), so readFileSync('config.json') works when you run node app.js from the project folder and fails from anywhere else, such as a cron job. Anchor files to the code with __dirname or import.meta.dirname instead. process.chdir() moves the whole process and is not available inside worker threads.
D. CommonJS modules
The cost is paid once per file, because later calls hit the cache, which is why modules are usually required at startup. A require placed inside a request handler makes the first request that reaches it pay for disk reads and compilation, or for parsing a large .json file, while every other connection waits. To load on demand without blocking, use await import().
The cache key is the resolved filename, not the string you passed, so two copies of one package in different node_modules folders run twice, and their classes fail instanceof checks against each other. On a case-insensitive disk, ./foo and ./FOO also load twice. Deleting require.cache[require.resolve(id)] forces a reload; import never consults require.cache, since the ESM loader keeps its own cache.
exports starts out as a second reference to the same object, so exports.x = 1 works while exports = {...} only repoints the local name. The reverse trap is quieter: after module.exports = { a: 1 }, a later exports.b = 2 writes to the abandoned original and disappears. Assign module.exports once, at the top level, and never inside a callback.
The module that closes the loop gets whatever its partner had exported up to that line, so destructuring at the top captures undefined for good, and Node prints a warning about reading a missing property inside a circular dependency. If the partner later replaces module.exports, the early holder keeps the old object forever. Reading mod.name at call time, not at load time, survives the cycle.
The search tries node_modules in the calling file's directory, then in each parent up to the filesystem root, skipping folders already named node_modules. Running the script from another directory therefore changes nothing, while moving the file does. A package that exists only higher up the tree resolves by accident, which is how an undeclared dependency works locally and fails in a clean install.
The exact filename is tried before any extension is added, and .cjs or .mjs is never appended, so those must be written out. For a directory, the main field of its package.json wins, then index.js, index.json and index.node. Directory loading is now marked Legacy in favor of exports, and import refuses a directory with ERR_UNSUPPORTED_DIR_IMPORT.
Both are absolute, with symlinks resolved, and describe the file containing the code, not the entry script or the working directory. In an ES module, using either throws a ReferenceError saying it is not defined in ES module scope. The replacements are import.meta.dirname and import.meta.filename, added in Node 21.2 and 20.11 and no longer experimental in Node 24.
Because the file body is a function body, a top-level return is legal in CommonJS, arguments exists with five entries, and var stays local to the file. Those five names are parameters, not globals, which is why they vanish in ES modules, where top-level this is undefined. Redeclaring one of them, as in const require = ..., is also a signal Node uses to detect ES module syntax.
It arrived behind --experimental-require-module in 22.0, lost the flag in 22.12 and 23.0, and was marked stable in 24.15. You get the module namespace object, so a default export sits under .default unless the module exports a value named 'module.exports'. Top-level await anywhere in the imported graph throws ERR_REQUIRE_ASYNC_MODULE; reach for import() there.
E. ESM, package.json and module resolution
The nearest package.json above the file decides, and the upward search stops at a node_modules folder, so a nested package.json that omits type resets its whole subtree to the default. That default is CommonJS, except that since Node 22.7 a .js file with no marker is reparsed as ESM when it contains import or export, at some startup cost. Package authors should always state type.
The whole static graph is linked before any code runs, then evaluated depth first, so a console.log written above an import still prints after the dependency's top-level code. Setting process.env before importing a config module therefore cannot work. Move the setup into its own module imported first, since siblings evaluate in import order, or use await import() where order matters.
The specifier resolves relative to the calling file, and the promise resolves to a namespace object, so a CommonJS module's module.exports appears as .default. Since 22.12, require() can also load ES modules, but only import() handles a graph containing top-level await. Pass absolute paths as pathToFileURL(p).href so Windows drive letters and characters such as # or % survive.
Relative and absolute specifiers must name the actual file, so import './utils' fails with ERR_MODULE_NOT_FOUND (Node even suggests the .js it skipped), and a folder import fails with ERR_UNSUPPORTED_DIR_IMPORT. Bare package names differ: they go through the package's exports map, which can expose extensionless subpaths. Compiled TypeScript must still carry .js in its import specifiers.
It is a percent-encoded file: URL, so stripping the file:// prefix leaves %20 where spaces were and mangles Windows drive letters; fileURLToPath decodes it correctly. new URL('./data.json', import.meta.url) points at a sibling file and can go straight to fs. import.meta.dirname and import.meta.filename exist only for modules loaded from file: URLs.
Only modules that depend on the awaiting one pause; a sibling import with no link to it still evaluates while the await is pending. A promise that never settles ends the process with exit code 13. require() of any graph that contains top-level await throws ERR_REQUIRE_ASYNC_MODULE, so a library using it forces its CommonJS consumers onto import().
Node scans the CommonJS source before running it and recognizes common shapes such as exports.name = ... and module.exports.name = ...; computed keys and Object.assign(exports, ...) are missed. Detected names are copied once when the module loads, so a later change to module.exports.name is invisible through the named import but visible through the default import.
Importing an unlisted subpath throws ERR_PACKAGE_PATH_NOT_EXPORTED, and that includes pkg/package.json unless it is listed, which is why adding exports to an existing package is a breaking change. The fence is soft: an absolute path into node_modules still loads the file. When a package has both fields, exports takes precedence over main.
Node walks the keys in the order written and takes the first match; how specific a condition is does not matter, so a default key placed first shadows every other condition. When import and require point at different files, one process can load both copies, giving two module instances with separate state and failing instanceof checks, which is the dual package hazard.
An ES import is a read-only view of the exporter's variable, so it reflects every later count++ inside the exporter, and assigning to it in the importer throws TypeError. require hands back an object instead: destructuring copies the values at that moment, and reassigning a local let in the exporter never reaches callers unless the exported property itself is written.
F. EventEmitter and the event pattern
Order is first in, first out unless you use prependListener, which inserts at the front. A listener that throws stops emit on the spot, so later listeners never run and the error reaches the caller. Adding the same function twice registers it twice, and each removeListener call takes out one copy. A once listener can be removed with the original function reference.
The emitted value is thrown as it is, and a value that is not an Error is wrapped in ERR_UNHANDLED_ERROR. A listener added under events.errorMonitor sees the error without counting as a handler, so the crash still happens. Sockets, streams and servers are emitters, which is why a single connection reset can take down a process when its socket has no 'error' listener.
Every listener runs inside the emit call, on your stack, so a slow listener slows the emitting code, and code after emit already sees the effects of all of them. The return value is true when at least one listener existed. An async listener's promise is ignored unless captureRejections is enabled, so a throw inside it becomes an unhandled rejection, not an 'error' event.
The default threshold is 10 per event name, and crossing it prints a MaxListenersExceededWarning while still adding the listener. In a long-running server it usually means something adds a listener per request and never removes it. emitter.setMaxListeners(n) changes the threshold for one emitter, and events.defaultMaxListeners changes it for all of them at once.
off and removeListener compare functions by identity, so calling off with a freshly written arrow function removes nothing and reports no error. Keep the handler in a named variable, or attach it with once when a single call is enough. Every request that adds an anonymous listener to a long-lived emitter, such as process or a shared socket, leaks that closure and everything it captured.
The promise resolves with an array of every argument the event carried, so const [value] = await once(ee, 'data') is the usual shape. Because code after an await resumes later, an event emitted in the same tick between two await once() calls is missed. Pass { signal } to stop waiting. An EventTarget has no special 'error' behavior, so only emitters get the early rejection.
More differences follow. EventTarget ignores a second registration of the same listener, keeps calling the remaining listeners when one throws, and gives 'error' no special meaning, reporting a throw as an uncaught exception. EventEmitter registers duplicates, stops emit at the first throw and crashes on an unheard 'error'. AbortSignal is an EventTarget.
G. Errors and asynchronous failure
On success the first argument is null or undefined, so test with if (err) and return from that branch, or the success path runs anyway with missing data. Calling the callback twice, or throwing inside it, are the other classic bugs. util.promisify converts any function that follows the convention; fs.exists breaks it with a lone boolean, one reason it is deprecated.
The try block finishes as soon as the call returns, long before the callback fires on a later turn, so an error thrown inside the callback escapes as an uncaught exception. Check the err argument inside the callback, or switch to the promise API and put the await inside the try. An 'error' event that a stream emits later escapes the same way.
An exception can unwind halfway through an operation, leaving locks held, transactions open and data half written. Use the handler only for synchronous cleanup and logging, then exit nonzero and let a supervisor restart the process. An exception thrown inside the handler is not caught again; the process exits with its stack trace. 'uncaughtExceptionMonitor' observes without preventing the crash.
Node 15 switched the default --unhandled-rejections mode from a warning to throw, raising the rejection as an uncaught exception unless an 'unhandledRejection' listener exists, with exit code 1. A handler attached later, say in a setTimeout, comes too late, and so does a rejected promise you meant to await after another await; start awaiting it right away or use Promise.all.
Node changes err.code only in a major release, while message wording can change in any release, so if (err.code === 'ENOENT') keeps working where a regex on the message eventually breaks. System errors carry POSIX-style codes such as EACCES, and Node's own errors use ERR_* names listed in the error reference. Your own errors can follow the pattern by setting a code property.
errno is a negative number from libuv, and util.getSystemErrorName(err.errno) turns it back into a name. path and dest mostly come from filesystem calls, so a failed rename names both files, though a failed spawn also sets path to the command; network failures carry address and port. An error handed to an fs callback has no JavaScript stack frames at all, so syscall and path are your best clues.
Supported since Node 16.9, the option sets a cause property only when you pass one. console.log and util.inspect print the whole chain, each link with its own stack, so the original code and stack survive a rethrow. A logger that prints only err.message drops the chain, and so does JSON.stringify, which skips non-enumerable fields and turns an Error into {}.
Read err.errors for the individual failures, in input order for Promise.any, which rejects only when every input does. Since Node 20, autoSelectFamily is on by default, so a failed connection to a host with both IPv6 and IPv4 addresses arrives as an AggregateError with an empty message but a code such as ECONNREFUSED, and logging only the message prints nothing useful.
V8 rebuilds the chain of awaiting functions, printing frames such as at async outer, but only through await: inside a .then() callback or an event callback you see only the frames below that callback. An error passed to an fs callback has no JavaScript frames at all. Prefer await with the promise APIs, and rethrow with { cause } so the original stack travels along.
Promise.all stops waiting, not working: the other operations keep running, their side effects still happen, and their later rejections are absorbed silently. Use Promise.allSettled when you need every result, since it never rejects and returns { status, value } or { status, reason } per input. To actually stop the rest, share an AbortSignal and abort it on the first failure.
H. Buffers and binary data
Any API that accepts a Uint8Array accepts a Buffer, but two inherited behaviors differ. The deprecated buf.slice() returns a view, while Uint8Array.prototype.slice copies. And buf.buffer can be a larger shared pool, because small buffers are carved out of one allocation, so always pair it with buf.byteOffset and buf.length before handing it to another API.
The skipped zero-fill means old data, possibly secrets from elsewhere in the process, can sit in the new Buffer, so overwrite every byte before sending or logging it. Small allocUnsafe buffers are also carved from a shared internal pool, which Buffer.alloc never uses. Choose allocUnsafe only when you fill the buffer immediately, as a target for copy or fs.read.
The same names work in both directions: buf.toString('hex') shows bytes as digits, while Buffer.from('ff', 'hex') is one byte and Buffer.from('ff') is two. A trailing odd hex digit is silently dropped, invalid UTF-8 decodes to U+FFFD replacement characters instead of throwing, and 'binary' is just an alias for 'latin1'. Use the same encoding on both sides.
str.length counts UTF-16 code units, Buffer.byteLength(str) counts UTF-8 bytes, and neither counts what a reader sees as one character. A smiley outside the Basic Multilingual Plane is 2 units and 4 bytes, a symbol inside it can be 1 unit and 3 bytes, and a family emoji joined with zero-width joiners is 8 units and 18 bytes. Size Content-Length and byte limits with Buffer.byteLength.
buf.subarray() and the deprecated buf.slice() both return views onto the same memory, so a write through either changes the original, and even a tiny view keeps the whole parent allocation alive. When you need an independent copy, use Buffer.from(view) or Uint8Array.prototype.slice, which copies even though Buffer's own slice does not.
Buffer.concat copies every input, so changing a source afterward does not affect the result; a totalLength smaller than the sum truncates and a larger one pads with zeros. buf.copy(target, targetStart) never grows the target and returns how many bytes it wrote, so a target that is too small truncates silently. Check that return value when sizes are computed.
Network protocols and file formats fix their byte order, so the bytes 0 0 1 0 must read as 256 big-endian even on a little-endian laptop, where the LE reader returns 65536. Readers and writers come in BE and LE pairs, and out-of-range offsets throw ERR_OUT_OF_RANGE. A Uint32Array view silently uses the host's order instead, so parse binary formats with these readers or a DataView.
Chunk boundaries fall wherever the network or disk put them, so a three-byte euro sign split across two chunks decodes as replacement characters when each chunk is converted separately. StringDecoder.write holds back the incomplete bytes until the next call, and end() flushes or replaces leftovers. readable.setEncoding('utf8') and TextDecoder with { stream: true } handle this for you.
In Node the decoders are lenient: Buffer.from(s, 'base64') accepts the URL-safe alphabet and 'base64url' accepts standard input, so the difference shows only in output. Encoding with 'base64url' gives - and _ instead of + and /, with no = padding, which strict decoders such as the global atob reject. Match what the receiving system expects; JWT segments use base64url.
I. Streams and backpressure
Readable produces data, Writable consumes it, Duplex has two independent sides as a TCP socket does, and Transform is a Duplex whose output is computed from its input, as gzip's is. Each side keeps its own buffer. Choose Transform when every output derives from input, Duplex when the two directions are unrelated, and skip subclassing when Readable.from or a generator stage in pipeline will do.
Every Readable starts paused, and a 'data' listener, pipe() or resume() switches it to flowing. Removing the 'data' listener later does not pause it again, and calling resume() with no consumer throws the data away. Adding a 'readable' listener stops the flow so you pull with read() instead. Mixing those styles on one stream is a common source of lost chunks.
A false is advice, not refusal: that chunk was still buffered, and later writes are buffered too, so a producer that ignores it grows memory without limit. Wait for 'drain', for example with await once(stream, 'drain'), before writing more. pipe(), pipeline() and for await handle this for you; hand-written write loops are where it usually goes wrong.
In Node 24 the default is 64 KiB for byte streams (16 KiB on Windows) and 16 items in object mode. It is a threshold, not a cap: one large chunk can push the buffer well past it, and the stream stops asking for more. After setEncoding(), a readable measures characters instead of bytes. Raise it for throughput, or lower it to bound memory per stream.
pipe() forwards only data and the end signal. When the source errors, the destination is neither ended nor destroyed, so a file write stream keeps its descriptor open, and an error on the destination never reaches the source either. Each stream also needs its own 'error' listener or the process crashes. stream.pipeline exists to handle all of this.
It calls destroy(err) on every stage that has not already finished, then reports once through the callback, or through a promise from node:stream/promises, available since Node 15. Stages can be async generator functions. One caveat: in an HTTP server, a failed pipeline that includes req or res destroys the socket, so you can no longer send an error response.
Any JavaScript value except null can be a chunk, because push(null) is how a stream signals its end. highWaterMark then counts items, 16 by default, whatever their size. The mode must be chosen when the stream is constructed, since switching an existing stream is unsafe, and writing a plain object into a byte stream such as a file throws ERR_INVALID_ARG_TYPE.
Call this.push() once per output chunk, as often as you need, then call the callback exactly once to accept the next input; passing it an error destroys the stream. Input boundaries are arbitrary, so a line splitter must keep the partial last line between calls and emit it from _flush, which runs after the writable side ends and before 'end' fires.
The loop requests the next chunk only when its body finishes, so an await inside it pauses the source once the buffer fills. Leaving with break, return or a throw destroys the stream, and a stream error surfaces as an exception you can catch around the loop. Iteration consumes the stream, so a second loop over it finds nothing.
Object mode is on by default, so each yielded value becomes one chunk. A string or Buffer passed directly is emitted whole as a single chunk, not split per character or byte. Yielding null is an error, since null marks the end, and an array of promises can trigger unhandled rejections; an async generator that awaits each value avoids that.
end() queues an optional final chunk, lets the buffer drain and then emits 'finish', so every write lands. destroy() discards whatever is still buffered, emits 'close' (and 'error' if you pass one), and any later write fails with ERR_STREAM_DESTROYED. Writing after end() fails differently, with ERR_STREAM_WRITE_AFTER_END.
'end' fires only once every chunk has been consumed, so a readable nobody reads never emits it. 'finish' follows end() and the last flush, and 'close' comes last, after the underlying resource is released. When piping, the source's 'end' calls the destination's end(), and the source's own 'close' can arrive after the destination's. stream.finished waits for whichever applies.
Data goes to whichever consumers are attached when it flows, and then it is gone; a second for await loop over the same stream ends immediately with nothing. To give two consumers the same bytes, attach both before data flows, pipe the source into two destinations, or buffer the content yourself. The same rule applies to an HTTP request body you read in middleware.
J. Web streams and cross-standard interop
They share an idea but not an interface: a WHATWG stream offers getReader() and pipeTo(), a Node stream offers .on('data') and .pipe(), so calling res.body.pipe() fails. Both are async iterable, the easiest common ground, and stream.pipeline accepts web streams as stages since Node 18.16. The Web Streams API has been stable since Node 21.
Each helper wraps one stream in the other family's interface and passes backpressure across, so Readable.fromWeb(res.body) piped into a file streams a download in constant memory. Writable.fromWeb, Writable.toWeb and a Duplex pair cover the other directions. They were added in Node 17, and Readable.toWeb counts each chunk as size 1 unless you pass a strategy.
readableStream.locked turns true while a reader, a for await loop, tee() or an active pipeTo or pipeThrough owns the stream. A second getReader() then throws a TypeError with code ERR_INVALID_STATE, and calling cancel() on the stream itself rejects the same way. Call reader.releaseLock() to hand it over, or reader.cancel() while you hold it.
The source is pulled at the pace of the faster branch, and every chunk is queued for the other until it reads, regardless of any highWaterMark, so reading one branch to the end and abandoning the other leaves the whole body in memory. A default stream hands both branches the same chunk objects, so mutating one in place changes the other; a byte stream copies them for the second branch. Response.clone() is a tee underneath.
pipeThrough connects a readable to any { writable, readable } pair and returns the readable side, carrying backpressure through the chain. Web streams count chunks by default, not bytes: a TransformStream holds 1 chunk on its writable side and 0 on its readable side. Pass a ByteLengthQueuingStrategy when you want byte-based limits like Node streams use.
K. The file system
All three raise the same error codes. The Sync forms block the loop and throw, the callback forms pass the error first, and node:fs/promises rejects. Callbacks are fastest and allocate least, which matters only on hot paths. Sync calls are fine at startup and in CLI tools but costly inside a server handler. None of them makes concurrent writes to one file safe.
Memory use grows with the file, so a few concurrent requests for a large file can exhaust the heap, and fs.readFile refuses anything over 2 GiB with ERR_FS_FILE_TOO_LARGE. Use fs.createReadStream with pipeline, or readline over a stream for line-based input. An AbortSignal can cancel readFile's buffering, but not an operating system read already in flight.
Pass 'utf8' or { encoding: 'utf8' } to get a string. String-looking code often keeps working without it, because concatenation and JSON.parse convert the Buffer with toString(), which defaults to UTF-8, but data === 'yes' is always false and data.length counts bytes. readdir, by contrast, returns strings unless you ask for 'buffer'.
Descriptors are a finite per-process resource; leak enough and every new file or socket fails with EMFILE. Close in a finally block so the error path is covered too. A numeric descriptor from fs.open is never closed for you, and a FileHandle closing itself during garbage collection is unreliable. Node 24.2 marks FileHandle[Symbol.asyncDispose] stable, so await using can do the closing.
TOCTOU means time of check to time of use: another process can create, delete or replace the file between the check and the open. Open with a flag that encodes your intent, such as 'wx' to create only if absent, and handle EEXIST or ENOENT from that call. existsSync also returns false for a dangling symlink, and the async fs.exists is deprecated.
With recursive: true it returns the first directory it actually created, or undefined when everything already existed, which tells you whether you made it. It still fails when part of the path is a regular file: EEXIST if the target itself is a file, ENOTDIR if a parent is. Without the option, an existing directory raises EEXIST.
Each Dirent describes the entry itself, as lstat would, so a symlink to a directory reports isSymbolicLink() true and isDirectory() false; call stat only on the links you mean to follow. dirent.parentPath, stable in Node 24, gives the containing directory, which you need once the recursive option returns entries from many folders.
On a dangling symlink, stat fails with ENOENT while lstat succeeds, and only lstat can report isSymbolicLink() as true. Code that walks a directory tree should lstat first so it does not follow links into loops or out of a sandbox. existsSync follows links too, which is why it reports a broken link as missing.
Within one filesystem, rename swaps the directory entry in one step and replaces an existing file, which makes write-to-temp-then-rename the standard way to update a file without readers seeing half of it. Create the temp file in the destination directory, not in /tmp, which may be another mount: a rename across mounts fails with EXDEV, and copying then deleting is not atomic.
It rides on inotify on Linux, kqueue or FSEvents on macOS and ReadDirectoryChangesW on Windows, so one save can produce several events, eventType is only 'rename' or 'change', and filename may be null. On Linux and macOS a watch follows the inode, so a file deleted and recreated, as atomic-save editors do, stops reporting. Network and container mounts may report nothing.
writeFile defaults to the 'w' flag, which truncates, so editing part of an existing file needs 'r+'. The x suffix makes creation exclusive and fails with EEXIST, even for a dangling symlink. mode defaults to 0o666, is masked by the process umask (022 turns it into 0o644), and applies only when the file is created; it never changes an existing file's permissions.
L. Paths, URLs and name handling
path.join('/srv/app', '/etc/passwd') gives /srv/app/etc/passwd because join treats every segment as relative, while resolve restarts at the absolute segment and returns /etc/passwd. resolve always returns an absolute path, falling back to process.cwd() when no segment is absolute, whereas join can return a relative one. Neither checks that the path exists.
Plain path follows the host OS, so code that splits on / or builds paths by concatenation breaks on Windows, where path.sep is \ and drive letters appear. Use path.posix or path.win32 when the format is fixed regardless of host, such as URL paths, which always use /. Windows accepts both separators as input, but path methods emit backslashes.
extname takes everything from the last dot, so archive.tar.gz gives .gz, not .tar.gz. A leading dot does not start an extension, so .bashrc and .env have an empty extension and the whole name is the name. path.parse returns root, dir, base, name and ext together, and path.basename(p, '.txt') strips one known suffix.
It works on the string alone, so a/link/.. collapses to a even when link is a symlink whose real parent is somewhere else. It also merges repeated separators, keeps a trailing one, and on POSIX folds a leading // that a few systems treat specially. Use fs.realpath when you need to know where a path really leads, for example before a security check.
'/srv/app-secrets'.startsWith('/srv/app') is true, the classic traversal bug. Resolve the input against the root, compute path.relative(root, target), and reject a result that is .., starts with .. plus path.sep, or is absolute, which on Windows means another drive; a name like ..notes inside the root is fine. Run fs.realpath first if symlinks could lead outside.
fs does accept a URL object, but a string beginning with file:// is treated as an ordinary relative path and fails with ENOENT. url.fileURLToPath decodes percent escapes such as %20 and yields drive-letter paths on Windows, which slicing off the prefix gets wrong. url.pathToFileURL goes the other way, which import() needs for a Windows path or one containing # or %.
The error is a TypeError with code ERR_INVALID_URL. With a base, resolution follows browser rules: new URL('api/x', 'https://example.com/v1/users') replaces users and gives /v1/api/x, so a base meant as a directory needs its trailing slash. URL.canParse (Node 19.9) and URL.parse (Node 22.1), which returns null, test input without a try.
It percent-encodes &, = and # inside values, writes spaces as +, and lets append repeat a key, read back with getAll since get returns only the first. Decoding is symmetric, so a literal + in a query string reads back as a space. Hand it raw text, not something you already encoded, or %20 comes out as %2520.
M. HTTP servers and clients
req is a Readable and res a Writable, so a JSON handler must collect the chunks, Buffer.concat them and parse, and it should enforce its own size limit, because Node caps only headers (16 KiB by default), never bodies. Decode after concatenating rather than chunk by chunk, or multi-byte characters split across chunks turn into replacement characters.
Look up req.headers['content-type'], never a capitalized key; rawHeaders keeps the original spelling as a flat list of names and values. Duplicates are merged by rule: set-cookie becomes an array, cookie values are joined with ; , most others with , , and repeats of fields such as host or content-length are discarded unless you enable joinDuplicateHeaders.
The first res.write() or res.end() sends the status line and headers implicitly, after which changing statusCode does nothing and writeHead or setHeader throws. This bites error handlers that run after streaming has started. Check res.headersSent before trying to send an error status, and set every header before the first byte of body.
Node never ends a response for you. Once the request has fully arrived, requestTimeout and headersTimeout stop applying, and server.timeout defaults to 0, meaning none, so a handler that skips res.end() on one branch leaves the client waiting and the socket held until the client gives up. End or destroy the response on every path, and set server.timeout as a backstop.
http.globalAgent has kept sockets alive since Node 19, with a 5 second socket timeout, but its maxSockets per host defaults to Infinity, so nothing caps concurrency until you create an Agent with a limit; requests beyond it wait in a queue. Destroy custom agents you no longer need. fetch ignores these agents entirely and uses undici's own pool.
headersTimeout limits how long the complete header block may take (60 s by default), requestTimeout limits the whole request including its body (300 s since Node 18), and both answer with 408 and close. keepAliveTimeout, 5 s by default, closes idle connections between requests. server.timeout, the general inactivity limit, defaults to 0, meaning none at all.
On the client, a response whose body you never read keeps its socket busy, so with a maxSockets limit later requests queue behind it; call res.resume() to discard the body, or res.destroy() to drop the connection. A server is more forgiving: Node reads and discards an unread request body itself once the response finishes, and the connection is reused.
Calling res.write() with no Content-Length set makes Node send Transfer-Encoding: chunked, even when a single write is followed by end(), while a lone res.end(body) gets a Content-Length computed for you. If you set Content-Length yourself, count bytes with Buffer.byteLength, not string.length: by default Node sends a wrong count unchecked, and only strictContentLength = true makes a mismatch throw.
fetch never uses http.Agent or http.globalAgent, so agent options do not apply; customize it with an undici dispatcher instead. Proxy variables such as HTTPS_PROXY are honored only when NODE_USE_ENV_PROXY=1 (Node 24.0) or the --use-env-proxy flag (Node 24.5) turns proxy support on. process.versions.undici shows which undici release your build bundles.
The body is a web ReadableStream, so json(), text() or iterating it consumes it, bodyUsed turns true, and a second read throws a TypeError saying the body is unusable. Call clone() before the first read. It tees the stream, so reading one copy while ignoring the other leaves the entire body buffered in memory.
N. TCP, UDP, TLS and DNS
Its readable side delivers what the peer sent and its writable side queues what you send. When socket.write() returns false, the data is waiting in your process's memory rather than the kernel buffer, so stop writing until 'drain' fires, or connect the sockets with stream.pipeline(), which pauses for you. A writer that ignores the return value grows memory without bound against a slow client.
Five back-to-back write() calls can surface on the other end as a single 'data' chunk, and one large write can surface as several. Never treat a chunk as a message: prefix each message with its byte length, or end it with a delimiter and buffer until the delimiter appears. For newline-delimited text, a readline interface over the socket does the splitting for you.
Nagle's algorithm delays small writes so it can send fewer, fuller packets, favoring throughput over latency. A raw net socket starts with it on, since the noDelay option defaults to false, while http.createServer() defaults noDelay to true. Call socket.setNoDelay() with no argument for chatty request-and-response protocols; bulk transfers rarely benefit.
Call server.listen(port) with no host and Node binds the unspecified address, :: when IPv6 is available or 0.0.0.0 otherwise, so every interface accepts connections, and on most systems :: also covers IPv4. A development server or admin endpoint should pass '127.0.0.1' explicitly. The same rule makes --inspect=0.0.0.0 a remote-code-execution risk on an unfirewalled port.
The assigned port is known only after the 'listening' event, through server.address().port; before that, server.address() returns null. Read the port there and hand it to the test client instead of hard-coding one. Inside a cluster worker it changes meaning: every worker that calls listen(0) receives the same port, random the first time and reused after that.
One send() arrives as one 'message' or not at all, and loss goes unreported: a datagram larger than a link's MTU is silently dropped, so size payloads to the path MTU, often 1,500 bytes on Ethernet including headers, not the 65,507-byte protocol ceiling. The send() callback only confirms the datagram left your socket; ordering and delivery need sequence numbers and acknowledgments, or TCP.
By default a client refuses a server whose certificate does not chain to a trusted CA or does not match the hostname. With rejectUnauthorized: false the handshake completes anyway and only socket.authorized turns false, so anyone in the middle can present any certificate; NODE_TLS_REJECT_UNAUTHORIZED=0 does that process-wide. For a private CA, pass its certificate in ca instead.
dns.lookup calls getaddrinfo, so it honors /etc/hosts and resolves names the way ping does, but each call holds one of the four default pool threads that fs, crypto and zlib share, so slow DNS can stall file reads. dns.resolve*() queries the network asynchronously off the pool and skips /etc/hosts. socket.connect() and http.request() use lookup unless given a custom one.
O. Child processes
exec collects all of stdout and stderr in memory for one callback; past maxBuffer, 1 MiB by default, it terminates the child and fails with ERR_CHILD_PROCESS_STDIO_MAXBUFFER and truncated output. It also runs your string through /bin/sh. spawn hands you the output as streams while the child runs, which is what log tailing, large exports and long builds need.
exec, and spawn with shell: true, give one string to /bin/sh, so input containing ; id or $(id) runs as a command. spawn('cmd', [arg]) without a shell passes each element straight to the program, so metacharacters stay literal text. The protection vanishes if you add shell: true: the array is then only joined with spaces, unescaped, which Node 24 flags as runtime deprecation DEP0190.
fork launches a module in a fresh Node process from the parent's process.execPath, with its own memory and V8 instance; unlike POSIX fork(2), nothing is cloned. Messages are serialized as JSON by default, so a Date arrives as a string and a Map as {} unless you pass serialization: 'advanced'. A child with a 'message' listener stays alive until one side calls disconnect().
'pipe', the default, exposes child.stdout and its siblings, which you must consume. 'inherit' shares the parent's own descriptor, so output lands wherever yours goes, 'ignore' attaches /dev/null, and 'ipc' in one slot adds a message channel. A stream works only once it has a real file descriptor, so a file stream must reach its 'open' event first. Slots can be mixed, as in ['ignore', 'pipe', 'inherit'].
Because exactly one of code and signal is set in 'exit' and 'close', a check like if (code) reads a SIGTERM kill as success, since null is falsy. Shells report 128 plus the signal number, 143 here, and util.convertProcessSignalToExitCode(), added in v24.14.0, computes it. A child that traps the signal and exits by itself reports a normal code instead.
The OS pipe holds only a small, platform-specific amount, so once it fills, the child's next write blocks, the child never exits, and a parent awaiting 'exit' hangs with it. Piping stderr but reading only stdout deadlocks the same way once enough errors pile up. Read every piped stream, or set the slots you do not need to 'ignore'.
On Linux and macOS a child can keep running after its parent exits even without detached; unref() is what lets the parent exit instead of waiting. detached: true gives the child its own process group and session, so a Ctrl-C or hangup sent to the parent's group misses it, and on Windows it is what lets the child outlive the parent. Pair either with stdio not tied to the parent, such as 'ignore'.
execFile keeps the conveniences of exec, one callback with buffered stdout and stderr and the same maxBuffer cap, but starts the program directly with an argument array, so input cannot turn into shell syntax and no shell process is spent. You give up pipes, redirection and globbing; do those steps in JavaScript, or wire two spawned children together with streams.
P. worker_threads and multi-process scaling
Async fs, dns and network calls already leave your JavaScript thread free, so a worker that only awaits I/O adds startup cost and message copying for no gain. A worker pays off when synchronous computation such as parsing, hashing or image math would otherwise freeze the event loop for every request. Measure first: high event-loop delay under load is the symptom a worker cures.
A module imported in a worker is loaded and evaluated again, with its own copy of every top-level variable, cache and singleton, and a write to globalThis in one thread is invisible in another. process.env starts as a copy unless you pass SHARE_ENV, process.exit() ends only that thread, and signals are not delivered to it. Shared state must travel by message or live in a SharedArrayBuffer.
The receiver gets a fresh copy: cycles, Map, Set, Date, BigInt and typed arrays survive, but a class instance arrives as a plain object without its prototype, getters or private fields, and a Buffer arrives as a plain Uint8Array. A function or a URL in the message makes postMessage throw DataCloneError, and a large object costs a full copy on every send.
List it in the transfer list, as in postMessage(view, [view.buffer]), and the memory moves instead of being copied; afterward the sender's buffer has byteLength 0 and every view on it is empty. A small Buffer from Buffer.from() or allocUnsafe() usually lives in the shared pool, so it cannot move and is cloned along with the whole pool. MessagePort and FileHandle objects transfer too.
Every thread holding the buffer sees the same bytes, so plain a[0]++ from two workers races and loses updates: two workers each adding a million to one counter can finish well short of 2 million, while Atomics.add always lands on exactly 2 million. Use Atomics.load and Atomics.store for visibility and Atomics.wait with Atomics.notify to block and wake. A SharedArrayBuffer may not be placed in a transfer list.
new Worker(file, { workerData }) copies the value with the structured clone algorithm before the thread starts, so later changes to your original never reach the worker, a function inside it throws, and any MessagePort in it must also appear in transferList. A SharedArrayBuffer placed in it is shared rather than copied, which gives a worker live memory from its first line.
Each worker boots a new isolate and event loop and loads its modules from scratch, so it takes tens of milliseconds to come online and holds several megabytes of resident memory for its lifetime. One per request adds that delay to every response and lets a burst exhaust memory. As the docs advise, keep a fixed pool sized near os.availableParallelism(), queue tasks to it, and reuse the workers.
new MessageChannel() returns port1 and port2. Post one of them through an existing channel with it listed in the transfer list, or postMessage throws DataCloneError; the two workers then talk without relaying through the parent. A port with a 'message' listener keeps the event loop alive, so call port.close() when the conversation ends or the process never exits.
The worker is terminated, the parent's Worker emits 'error' with the thrown value, and 'exit' follows with code 1. With no 'error' listener attached, the event is rethrown in the parent and the main process crashes too. Attach 'error' and 'exit' handlers to every worker, and treat a non-zero exit as a lost task to retry or report.
How many workers to fork is your call; the docs' example uses os.availableParallelism(). Everywhere except Windows the default is round-robin: the primary accepts each connection and passes it to a worker. The alternative lets workers accept from a shared socket, where over 70% of connections have been seen landing on two of eight workers. Pick the policy with cluster.schedulingPolicy before the first fork.
Each worker is a separate process with its own heap, and round-robin sends a client's next connection to whichever worker is next in turn, so a session written in one is missing from another, an in-memory rate limiter sees only part of the traffic, and a cache warms once per worker. The docs warn that cluster provides no routing logic; keep shared state in Redis, a database or another external store.
Listen for cluster.on('exit') and call cluster.fork(), but check worker.exitedAfterDisconnect first so a deliberate shutdown is not undone. For a zero-downtime reload, replace one worker at a time: fork the new one, wait for its 'listening' event, then call disconnect() on the old one, which stops it accepting while existing connections finish. With no workers alive, new connections are refused.
Q. Cancellation and asynchronous context
Pass controller.signal to fetch, fs.readFile, timers/promises or child_process, then call controller.abort(reason) to cancel all of them at once; each sees the reason as signal.reason. Add your own cleanup listener with { once: true }, since lingering abort listeners leak, and check signal.aborted first, because a listener added after the abort never fires.
After the first abort(), signal.aborted stays true, and later abort() calls change neither it nor signal.reason. Any API handed that signal rejects immediately, so a retry loop that reuses one controller gives up on every attempt after the first cancel. Create the controller per attempt, and join it with a long-lived job signal through AbortSignal.any() when the whole job can also be canceled.
Added in v17.3.0 and v16.14.0, it aborts itself after the delay with a DOMException named TimeoutError as its reason. fetch rejects with that TimeoutError itself, while Node's own APIs reject with an AbortError whose cause is the TimeoutError, so match on the name you will actually receive. The deadline cannot be cleared early; pair it with a user signal through AbortSignal.any().
AbortSignal.any([user.signal, AbortSignal.timeout(5000)]), added in v20.3.0 and v18.17.0, returns a signal that aborts as soon as either input does, and its reason is copied from whichever fired first, so inspecting the reason tells a user cancel from a missed deadline. If one input is already aborted when you call it, the combined signal starts out aborted with that reason.
Node's core APIs reject a canceled call with an AbortError of code ABORT_ERR and put your signal.reason in cause; fetch instead rejects with the reason itself, a DOMException named AbortError by default or TimeoutError after AbortSignal.timeout(). Branch on err.name to cover both shapes, treat a cancel as an expected outcome rather than an incident, and rethrow anything else.
req.signal, added in v26.1.0 and backported to v24.16.0, is created lazily on first access and aborts if the request is destroyed or its socket closes before handling finishes. Hand it to fetch or a database driver so a vanished client stops costing work. Before v24.20.0 and v26.7.0 it also aborted after a normal completion, so keep work meant to outlive the response off it.
als.run(store, fn) makes store the result of als.getStore() inside fn and in every timer, promise and callback created from it, even after await; outside, getStore() returns undefined. Each request's own run() keeps concurrent requests apart. Since v24.0.0 it is built on AsyncContextFrame, and --no-async-context-frame restores the older async_hooks version.
Context is captured when an asynchronous resource is created, not when the callback is written. A callback queued inside run() but drained by a timer, pool or emitter created earlier runs in that older context and sees undefined, yet a listener registered earlier but emitted synchronously inside run() does see the store. Wrap such callbacks with AsyncLocalStorage.bind() or snapshot(), both stable since v23.11.0.
R. Cryptography
SHA-256 maps any input to a fixed 32-byte fingerprint, and anyone with the same input reproduces it, which suits integrity checks and cache keys. If you need the data back, encrypt it with a key, for example AES-GCM through createCipheriv. Hashing low-entropy values such as phone numbers or emails is not anonymization: an attacker hashes every candidate and matches the digests.
General hashes are built for speed, which is exactly what someone guessing against stolen digests wants. crypto.scrypt makes every guess cost tunable CPU and memory, with defaults of cost 16384, block size 8 and parallelism 1. Use the callback form, since scryptSync blocks the event loop, and store the parameters with the salt and hash so you can raise them later. v24.7.0 added crypto.argon2.
With a random salt per user, identical passwords hash differently, so precomputed rainbow tables are useless and each account must be attacked on its own. Use at least 16 random bytes from crypto.randomBytes(16), as the scrypt docs recommend, and store the salt openly beside the hash. A single global salt only makes the attacker build one new table, and a username is a predictable salt.
String === can stop at the first differing character, so response time hints at how much of a guessed token matched. crypto.timingSafeEqual(a, b) compares in constant time but throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH on unequal lengths, so compare fixed-length values such as HMAC digests of both sides. The docs warn that the code around it must not leak timing either.
The language spec asks only that Math.random() be roughly uniform, from an engine-chosen algorithm with no unpredictability guarantee, so tokens built from it can be guessed. Use crypto.randomBytes(n) for keys and tokens, crypto.randomInt(min, max) for a bias-free range with max excluded, and crypto.randomUUID() for v4 identifiers.
A v7 UUID keeps a millisecond Unix timestamp in its top 48 bits, so new keys append near the end of a B-tree index instead of landing on random pages. crypto.randomUUIDv7() arrived in v26.1.0 and was backported to v24.16.0; its docs warn the clock is not monotonic, so ordering is not strictly guaranteed. The embedded timestamp also reveals when a row was created, so keep v4 where that leaks something.
A plain SHA-256 of a message can be recomputed by whoever alters the message, so it proves nothing about the sender. crypto.createHmac('sha256', key) mixes a secret into the digest, so only key holders can produce a valid tag, whereas a homemade sha256(key + message) is open to length-extension forgery. Verify an incoming tag by recomputing it and comparing with timingSafeEqual, never ===.
In GCM and other counter-based modes the IV seeds the keystream, so two messages under the same key and IV share it: XORing the two ciphertexts yields the XOR of the plaintexts, and GCM's authentication key becomes recoverable too. Generate a fresh 12-byte IV per message with randomBytes(12), send it beside the ciphertext, and rotate the key well before NIST's limit of 2^32 random-IV messages.
On decryption, call setAuthTag() before final(); final() throws if the tag is missing or wrong. The trap is update(), which returns plaintext before the tag is checked: a tampered message decrypted to readable altered text, and only the later final() threw. Buffer the output and act on none of it until final() succeeds, and transmit the 16-byte tag along with the ciphertext and IV.
crypto.subtle, on the global crypto and on node:crypto alike, is the W3C Web Crypto API: every call returns a promise, keys are CryptoKey objects, and the code also runs in browsers. Classic node:crypto mixes synchronous calls like createHash with callback forms like scrypt, uses KeyObject and Buffers, and has streaming hashes and ciphers that Web Crypto lacks.
S. The permission model and hardening
Under --permission, stable since v23.5.0 and v22.13.0, Node 24 also restricts WASI, the inspector and, from v24.21.0, OpenSSL STORE loaders; denied calls throw ERR_ACCESS_DENIED. Grants are opt-in: --allow-fs-read, --allow-fs-write, --allow-child-process, --allow-worker, --allow-addons, --allow-wasi, --allow-inspector (v24.12.0) and --allow-openssl-store. Network stays open in Node 24; --allow-net came in v25.0.0.
Each grant names a path. An existing directory gets an implicit trailing wildcard, so --allow-fs-read=./data covers everything under it, while a directory that does not exist yet needs an explicit /*. Characters after a * are ignored, so /home/*.js means /home/*. Since v24.2.0 the entry script is readable without a grant, and symbolic links are followed even out of granted paths.
The checks live in Node's own fs, child_process and worker_threads code, so a package deep in node_modules gets ERR_ACCESS_DENIED exactly as your code does, with no per-module exceptions. A worker created without execArgv inherits the flags, but an explicit execArgv, even [], replaces them and the worker runs unrestricted, which is why --allow-worker prints a security warning.
process.permission exists only when the model is enabled, so write process.permission?.has('fs.write', dir) with optional chaining, or the same code throws a TypeError in a normal run. With no reference, has('fs.read') asks whether every read is allowed. process.permission.drop(), added in v24.20.0, gives up a grant permanently, but it does not close descriptors or sockets already open.
The docs call it a seat belt: it keeps trusted code from wandering into files or processes it was not granted, and it gives no guarantee against malicious code. File descriptors that are already open bypass it, symbolic links are followed out of granted paths, and flags like --env-file are read before it starts. Untrusted code needs OS isolation such as a container, a separate user or seccomp.
npm 11, the version bundled with Node 24, runs a dependency's preinstall, install and postinstall scripts by default, so npm install executes code from packages you never read. npm 12.0.0 blocks those unless the root package's allowScripts approves them, and older versions accept --ignore-scripts. Approval does not end the exposure: every module you import runs with your process's rights.
On a typical Linux or macOS host, any local user can read another process's full command line with ps, and shell history and CI logs keep a copy too, so --token=abc leaks. A process's environment is normally readable only by the same user and root, and a file with tight permissions is narrower still. Values loaded with --env-file land in process.env, which every child inherits unless you pass a trimmed env.
T. Diagnostics, profiling and memory
performance.now() counts from process start on a clock that time adjustments do not move, while Date.now() can jump when NTP or an administrator resets the clock, producing negative or inflated durations. Marks and measures stay in a global buffer until you call clearMarks() and clearMeasures(); past a million mark entries Node prints a MaxPerformanceEntryBufferExceededWarning.
monitorEventLoopDelay() returns a histogram that records nothing until you call enable(), and it reports nanoseconds, so divide by 1e6 for milliseconds. Read percentile(99) and max rather than the mean: a single 200 ms synchronous block showed up as a 204 ms max while the median barely moved. A p99 that climbs under load means callbacks are queuing behind CPU work.
Create the channel once with diagnostics_channel.channel('mylib:query') and guard expensive payload building with if (ch.hasSubscribers), so an unobserved publish costs only that check. Subscribers run synchronously inside publish(), so a slow one slows your code, and one that throws surfaces as an uncaughtException while the publisher carries on. Prefix channel names with your module's name.
Both default to 127.0.0.1:9229. --inspect-brk pauses before the first line of your script so breakpoints in startup code can hit, while --inspect-wait, added in v22.2.0, waits for a debugger without pausing. Binding to 0.0.0.0 lets anyone who reaches the port run code in your process. Sending SIGUSR1 to a running Node process starts the inspector on demand.
Write one with v8.writeHeapSnapshot() or --heapsnapshot-signal=SIGUSR2, open it in Chrome DevTools, and use the Comparison view to list what grew between a baseline and a later snapshot, then follow the retainer path to the code holding it. Writing a snapshot blocks the event loop and needs about twice the heap's size in memory, so an OOM killer can strike first.
New objects land in a small young generation that V8's scavenger clears in short pauses by copying out the few survivors. Objects that keep surviving are promoted to the old generation, which needs a costlier mark-compact collection. Caching data for seconds or minutes promotes it and grows the old space. --trace-gc prints one line per collection, labeled Scavenge for young-generation passes.
It takes MiB and caps only the old generation; the young generation, Buffers and native memory sit outside it. By default Node 24 sizes the heap from the smaller of physical memory and the cgroup limit, but raise the flag near the container's limit and the kernel's OOM killer ends the process before V8 reports heap exhaustion, because off-heap memory counts too. Leave real headroom.
process.memoryUsage() splits it: heapUsed and heapTotal are V8's heap, external counts C++ memory tied to JavaScript objects, arrayBuffers, part of external, holds Buffer data, and rss is everything resident. A 200 MB Buffer.alloc adds 200 MB to arrayBuffers at once while heapUsed barely moves, but its zeroed pages may not count toward rss until you write into them.
A Map used as a cache with no eviction keeps every key forever. An on() listener added per request to a long-lived emitter pins its closure, and past ten for one event Node prints MaxListenersExceededWarning. A small subarray() slice shares memory with its parent, so keeping the slice keeps the whole Buffer. Bound caches, pair every on with off, and copy slices you keep.
node --cpu-prof app.js samples the call stack every 1,000 microseconds by default and, at exit, writes a .cpuprofile file that Chrome DevTools opens as a flame chart. The file appears only on a clean exit: a process killed by an unhandled SIGTERM leaves none, so add a handler that calls process.exit(). The --cpu-prof flags have been stable since v22.4.0.
U. Testing with node:test
With no arguments it runs files with names like *.test.js, *_test.mjs or test-*.cjs and anything under a test/ directory, plus the .ts equivalents while type stripping is on, and it skips node_modules. A *.spec.js file is not matched, so pass your own glob in double quotes, as in node --test "**/*.spec.js". Any failing test sets the exit code to 1.
Since Node 24.0, a parent test waits for every t.test() subtest it starts, so a forgotten await no longer loses one: the subtest runs to the end and its own assertion error fails the run. Before Node 24, the same subtest is canceled when its parent returns and reported as test did not finish before its parent and was cancelled, hiding that error. Writing await t.test(...) keeps the test portable to Node 22.
In strict mode every loose method acts as its strict twin: equal compares with Object.is, so 1 and '1' differ, 0 and -0 differ and NaN equals NaN, while deepEqual also compares types and prototypes. It does not make equal deep: equal({ a: 1 }, { a: 1 }) still fails, since two objects compare by reference. Failure messages for objects show a diff.
assert.rejects() returns its own promise, so without await the test ends first and a mismatch surfaces later as an unhandled rejection: the test is marked passed and only the file fails, for asynchronous activity after the test ended. If you pass a function that throws synchronously or returns a non-promise, it rejects with that error and skips your matcher. Always await it.
t.mock.method(obj, 'name', impl) inside a test is undone automatically when that test finishes, while the top-level mock export stays in place until you call mock.restoreAll() or mock.reset(), so a stub can leak into every later test. The replaced method carries a .mock record whose calls array holds each call's arguments and result, and callCount() counts them.
t.mock.timers.enable() swaps setTimeout, setInterval, setImmediate and, by default, Date, with Date.now() starting at 0 unless you pass now; { apis: ['setTimeout'] } leaves the rest alone. Advance time with tick(ms). A setTimeout destructured from node:timers at import keeps real timing, since only the globals and module objects are swapped. Stable since v23.1.0.
By default node --test runs each file in its own child process, up to os.availableParallelism() - 1 at once, so globals and module state cannot leak between files. Inside one file, tests run one at a time because the concurrency option defaults to false; set it on test() or describe() to overlap async tests. --test-isolation=none loads every file into a single process.
Order stays deterministic unless you ask: --test-randomize, added in v24.16.0 and still in early development, shuffles both files and queued tests and prints the seed, and --test-random-seed=N replays that order. Subtests awaited one by one keep their declared order. A test that passes alone but fails when shuffled depends on state left behind by another test.
node --test --experimental-test-coverage reads V8's own coverage data, so no transform rewrites your code, and prints line, branch and function percentages after the run; it is still experimental in Node 24. Test files and node_modules are excluded unless you adjust --test-coverage-include or --test-coverage-exclude, and --test-coverage-lines=80 fails a run below 80 percent.
V. The CLI, flags and configuration
node --run test, added in v22.0.0, finds the nearest package.json, prepends each ancestor's node_modules/.bin to PATH, and runs the script from that package's directory. It deliberately skips pretest and posttest and sets no npm_* variables such as npm_lifecycle_event, exposing NODE_RUN_SCRIPT_NAME instead, so scripts that read npm's variables can break. Arguments after -- are appended.
node --watch app.js, stable since v22.0.0, restarts when the entry file or anything it imports changes; a config file read through fs is not tracked. --watch-path=./src replaces that dependency tracking rather than narrowing it, so only the listed paths trigger a restart, and it is supported only on macOS and Windows, throwing ERR_FEATURE_UNAVAILABLE_ON_PLATFORM elsewhere.
--env-file=.env, added in v20.6.0 and stable since v24.10.0, fills process.env before your code runs, and a variable already set in the real environment wins over the file. A missing file stops startup unless you use --env-file-if-exists. Every value is still a string, so DEBUG=false is truthy, and the flag must come before the script name.
Node reads NODE_OPTIONS before the command line, so a single-value flag given on the command line wins, and repeatable flags like --require run the environment's copies first. Children inherit it only because they inherit the environment: a child spawned with a custom env that omits it starts without those flags. Options that make no sense there, such as -p or a script path, abort startup.
The runtime never consults NODE_ENV, but Express, bundlers and many other libraries check process.env.NODE_ENV === 'production' to drop debug paths, so leaving it unset can quietly run them in development mode. It also does not activate the "production" or "development" export conditions in package.json; selecting those takes the --conditions flag.
node --inspect app.js starts the debugger, but node app.js --inspect hands --inspect to your script in process.argv, and Node never acts on it; Node's own flags appear in process.execArgv. The documented shape is node [options] [V8 options] script [--] [arguments]. The same slip hides in npm run start -- --flag, which appends the flag after the script name.
node --conditions=development app.js, stable since v22.9.0 and also spelled -C, makes a package's "development" export branch eligible alongside the built-in "node", "import", "require" and "default". The package's key order still decides: the first matching key in its exports object wins, whatever order your flags take. Placed after the script name, the flag goes to the script instead.
Both load a module before the entry point, and again in every worker thread, forked process and cluster worker. --require modules run first and follow CommonJS resolution, accepting ES modules too since v23.0.0 and v22.12.0, while --import follows ESM rules and is still marked experimental in Node 24. Tracing agents attach this way so they can patch http before your code imports it.
W. Modern Node: TypeScript, SQLite, packaging and releases
Node overwrites the annotations with whitespace and runs what is left, so line and column numbers match your source with no source maps, but it never reads tsconfig.json: no paths aliases and no downleveling to an older target. It is on by default since v23.6.0 and v22.18.0 and stable since v24.12.0. Imports need the real .ts extension, and type-only imports need the type keyword.
Stripping can only delete syntax, so an enum, a namespace holding runtime values, a constructor parameter property or an import alias stops the file with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX; a type-only namespace is fine. Node 24 still has --experimental-transform-types, but v26.0.0 removed it, so compile such files with tsc or a loader, and set erasableSyntaxOnly to catch them early.
A file that assigns 42 to a string variable runs and prints 42; Node rejects only syntax it cannot strip. Add tsc --noEmit to CI or a pre-commit hook, with erasableSyntaxOnly and verbatimModuleSyntax set so the checker also flags whatever Node cannot run. Because .ts test files execute directly, a green test run says nothing about type correctness.
DatabaseSync and its prepared statements run synchronously, so a slow query or a large scan blocks every request waiting on the event loop; push heavy work to a worker. The module dropped its --experimental-sqlite flag in v23.4.0 and v22.13.0 and became a release candidate in v24.15.0, not yet stable. The permission model docs also warn that node:sqlite can reach files outside the fs checks.
With db.prepare('SELECT * FROM users WHERE name = ?').all(name) the value travels separately from the SQL text, so input like x' OR '1'='1 is compared literally instead of rewriting the query. Named placeholders such as :name take an object. Placeholders stand only for values; a table or column name cannot be bound, so check identifiers against an allowlist.
In Node 24 it takes several steps: bundle the app into one CommonJS file, generate a blob with --experimental-sea-config, copy the node binary, inject the blob with a tool such as postject, and re-sign on macOS. Inside the executable, require() loads only built-in modules, which is why bundling comes first. v25.5.0 added a one-step --build-sea flag.
Global since v17.0.0, it keeps cycles and shared references, Map, Set, Date, RegExp, BigInt, typed arrays, Error objects and keys whose value is undefined. A JSON round trip throws on a cycle, turns a Date into a string and a Map into {}, and drops undefined. It still throws DataCloneError on functions, and class instances come back as plain objects.
From Node 27 on, a line branches each October, spends six months in Alpha, ships its first release in April, stays Current for six months, then gets 30 months of LTS with no Maintenance phase. Lines through 26 follow the old rule: a major every six months, with only even numbers reaching LTS. Node 25, end of life in June 2026, was the last line that never did.
Current takes most non-breaking changes from the main branch as they land, so code tracking it keeps moving, and under the old schedule odd-numbered lines reached end of life without ever becoming LTS. The nodejs.org guidance is that production should run Active LTS or Maintenance LTS: Active LTS still gets audited features and fixes, while Maintenance gets critical bug and security fixes.
There are four kinds: documentation-only (silent, though some warn under --pending-deprecation), application (warns once, for code outside node_modules), runtime (warns for all code) and End-of-Life (removed or about to be). --throw-deprecation turns runtime deprecation warnings into thrown errors, which makes a test suite fail on them, and --trace-deprecation shows the calling stack.
X. The rest of the standard toolkit
util.promisify(fn) assumes the last parameter is an error-first callback, and a method promisified on its own loses this, so bind it first or call the result with .call(obj). util.callbackify(asyncFn) goes the other way and wraps a falsy rejection reason such as null in an Error with a reason field, because a falsy first argument would signal success.
With the default depth of 2, { a: { b: { c: { d: 1 } } } } prints as { a: { b: { c: [Object] } } }, and arrays stop after 100 items with a '... 20 more items' line for 120. The data is intact; only the display is cut. Use console.dir(obj, { depth: null }) or util.inspect(obj, { depth: Infinity }) to see everything, or JSON.stringify(obj, null, 2) for plain data.
console.info and console.debug are aliases for log on stdout and console.warn aliases error on stderr, with no threshold, timestamps or JSON. On POSIX, writes to files and terminals are synchronous and can stall the event loop, while writes to pipes are asynchronous, so process.exit() can drop the tail of piped output. Production code wants a structured logger.
Brotli's default quality is 11, its maximum, which suits precompressing static assets: on a 4 MB binary sample it took about 7.5 seconds against 0.13 seconds for gzip, while quality 5 beat gzip on both speed and size. For responses compressed per request, set BROTLI_PARAM_QUALITY to a mid value such as 4 or 5, and stream through createBrotliCompress() with pipeline().
os.cpus() lists every logical core the host reports, and it can come back empty when /proc is unavailable. os.availableParallelism(), added in v19.4.0 and v18.14.0, wraps libuv: on Linux it honors the CPU affinity mask and, with the libuv 1.50 bundled in Node 24.0, a cgroup CPU quota too. The docs say not to size thread or process pools from os.cpus().length.
A context gets its own global object, but the context object itself comes from your realm: inside runInNewContext, this.constructor.constructor('return process')() returned the real process on Node 24.22, and require and the filesystem are one step further. The docs state that node:vm is not a security mechanism. Run untrusted code in a separate process with OS-level isolation.
Y. HTTP/2
The Core API hands you (stream, headers) on a 'stream' event and you answer with stream.respond(), with nearly the same events on client and server. The Compatibility API takes an (req, res) handler, and on a secure server with allowHTTP1: true one handler can serve both protocols if it sticks to HTTP/1's public methods. Differences still leak through: the status message is ignored.
Every session is bound to exactly one socket, and destroying either destroys both; reading or writing that socket yourself can leave the session unusable. Servers receive sessions for incoming connections, clients create them with http2.connect(), and one session carries many concurrent request() calls. session.type equals NGHTTP2_SESSION_SERVER or NGHTTP2_SESSION_CLIENT.
Since v19.0.0, a client whose ALPN list lacks h2, or http/1.1 when that is allowed, fails the TLS handshake outright, so the event remains only for clients that send no ALPN at all. With no listener the connection is dropped; with one, the server destroys the socket after unknownProtocolTimeout, 10,000 ms by default, if you have not. No browser is known to speak unencrypted HTTP/2.
They are lowercase, start with a colon, and the response carries :status in place of a status line. A request may carry :authority or host; the Compatibility API falls back to host, but Core API code reading headers[':authority'] must do that fallback itself. Setting :status through res.setHeader() throws ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED, and trailers may not contain pseudo-headers.
While the peer has not acknowledged, session.pendingSettingsAck is true and the old values still govern the session, so do not assume a larger stream limit or window is in force the moment you call settings(). Several SETTINGS frames can be outstanding at once. In Node 24 the settings() callback runs when the ACK arrives, just before 'localSettings' is emitted, and receives ERR_HTTP2_SETTINGS_CANCEL if the frame is canceled.
Calling setLocalWindowSize(1048576) twice leaves the connection-level window at 1 MiB, not 2 MiB, because the argument is the desired total. A window already advertised to the peer cannot be taken back, so an increase takes effect at once while a decrease applies only as that credit is used up. Per-stream windows are a separate setting, initialWindowSize.
session.close() winds a session down gracefully, letting open streams finish and refusing new ones, while session.destroy(err, code) ends it at once with a final GOAWAY. session.goaway() sends the frame alone; a peer receiving GOAWAY emits 'goaway' with the last stream id it processed and shuts down automatically. stream.close(code) sends RST_STREAM for one exchange, NO_ERROR by default.
Call stream.pushStream({ ':path': '/app.css' }, cb) and respond on the stream the callback receives, after checking stream.pushAllowed: it mirrors the client's latest SETTINGS_ENABLE_PUSH, and a client can send enablePush: false to refuse every push. Calling pushStream() from inside a pushed stream throws. A Node client receives pushes as 'stream' events on its session.
HPACK's dynamic table is shared by every stream on a connection, so an attacker able to add guesses alongside your requests can learn a low-entropy secret from compressed sizes. List names under the symbol key, as in { [http2.sensitiveHeaders]: ['cookie'] }, to keep them out of the table. Node does this automatically for authorization and short cookie values, and marks received headers the same way.
A plain HTTP/2 CONNECT turns one stream into a TCP tunnel to :authority and must omit :path and :scheme. The extended form from RFC 8441 adds :protocol, the route WebSockets take over HTTP/2, and a client may send it only after the server's SETTINGS carry enableConnectProtocol: true, so check 'remoteSettings' first. Once a server enables it on a session, it cannot be disabled.
Z. Reading input line by line
createInterface({ input: fs.createReadStream('log.txt') }) splits a file, and the same call works on a socket or child.stdout, since input is the only required option. Omit output when you only read, and no prompt is written. The interface starts consuming input the moment it is created, so an await between creating it and iterating can miss lines.
for await pulls lines on demand and pauses the source when they pile up: with the loop stalled after ten lines, about 7,600 of 200,000 lines had been read, while a 'line' listener drained all 200,000. Two costs come with it: errors on the input stream are not forwarded into the loop, so listen for 'error' on the stream, and the docs call it slower than 'line' events.
The default is 100 ms, and smaller values are raised to 100. File and network data arrive in chunks, so a chunk ending right after \r followed by a slow next chunk yields a phantom empty line; a 250 ms gap turned a\r\nb into a, an empty string and b. Set crlfDelay: Infinity whenever you read files or sockets that use Windows line endings.
The check happens once, at creation, so node app.js | tee out.log silently loses prompt redrawing, arrow-key editing, history and cursor control. In the other direction, forcing terminal: true onto a pipe or file writes ANSI escape codes into it. Pass terminal: true or false explicitly when behavior must not depend on how the program was launched.
Importing node:readline/promises, added in v17.0.0 and stable since v24.0.0 and v22.17.0, makes await rl.question('Name? ') resolve with the answer and accept a signal to cancel. In the callback module, question passes the answer as its only argument, not error-first, and throws if called after close(), where the promise version rejects instead.
With terminal false, nothing is recorded even if you pass history or historySize, whose default is 30. The 'history' event receives the history array after each change; save it there to persist across runs, or edit the array in place to remove a line such as a password before it is kept. removeHistoryDuplicates: true drops older repeats.
Keentune is not affiliated with or endorsed by the organizations whose documentation informs these maps.
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.