One thread runs every callback in a Node process, so anything that holds it holds the whole program. Three biases decide
most calls:
- The
engines.node floor decides what may be written, not the node binary on the machine.
- Read an API's stability index before depending on it. Node ships an API for years before committing to it, and an
experimental one can be deleted without a deprecation cycle.
- Check the standard library before adding a dependency. It absorbed
fetch, a test runner, a file watcher, glob,
SQLite, .env parsing, a script runner, and TypeScript execution.
Node Version
engines.node in package.json is the floor a project claims. Read it before writing code and never reach for an API
above it. Nothing enforces it at run time — Node never reads the field, and npm checks it only under
engine-strict=true — so the floor holds by discipline and by CI running the oldest major it names.
The installed binary is not the floor. node --version reports what one machine has, not what the project supports.
Support windows, which decide whether a floor is defensible:
- 20 — end of life 2026-04-30. A floor of
>=20 claims a runtime that receives no fixes at all.
- 22 — maintenance from 2025-10-21, end of life 2027-04-30. Security and critical fixes only; no new capability.
- 24 — Active LTS from 2025-10-28, maintenance from 2026-10-20, end of life 2028-04-30.
- 26 — released 2026-05-05, Active LTS from 2026-10-28, end of life 2029-04-30.
Floor version per feature — the APIs and behavior changes that decide what may be written.
- 20.19.0 —
require(esm) unflagged on the 20 line; syntax detection on by default
- 22.0.0 —
node --run; fs.glob; WebSocket global; watch mode Stable; assert { type: 'json' } removed
- 22.12.0 —
require(esm) unflagged on the 22 line; JSON modules Stable
- 22.13.0 — Permission Model Stable and
--permission accepted; node:sqlite unflagged
- 22.15.0 —
module.registerHooks()
- 22.16.0 —
import.meta.dirname and import.meta.filename Stable; --experimental-config-file
- 22.18.0 — type stripping on by default;
import.meta.main
- 22.21.0 —
--env-file Stable; NODE_USE_ENV_PROXY
- 24.0.0 —
--experimental-permission renamed --permission; AsyncLocalStorage on AsyncContextFrame;
URLPattern global; test runner awaits subtests; url.parse() runtime-deprecated (DEP0169); args with
{ shell: true } runtime-deprecated (DEP0190)
- 24.12.0 — type stripping Stable
- 24.20.0 —
--permission-audit
- 26.0.0 —
Temporal global; module.register() runtime-deprecated (DEP0205); --experimental-transform-types
removed; node:_stream_* removed. It also inherits what 25 added and neither 22 nor 24 received: --allow-net, and
with it network confinement under --permission
- 26.3.0 —
permission.drop()
No odd major is a defensible floor. Node 21, 23, and 25 are end of life — 25 since 2026-06-01 — so a feature that
arrived on one is reachable only from the LTS major above it.
Read [${CLAUDE_SKILL_DIR}/references/versions/node-NN.md] — one file per LTS major: node-20.md, node-22.md,
node-24.md, node-26.md — when writing against a feature near the floor, and whenever raising the floor. Each carries
what its major added, the stability each addition holds, the behavior it changed, and the traps it introduced.
Stability Index
Every documented API carries one, and it decides how the API may be used, not merely how finished it is.
- 2 - Stable — protected by semver. Safe in a published library's surface.
- 1 - Experimental — outside semver, and the level most unfinished APIs print. Three subdivisions refine it: 1.2 -
Release candidate, where no further breaking change is anticipated but none is ruled out; 1.1 - Active
development, nearing minimum viability; 1.0 - Early development, unfinished and expecting substantial change.
- 3 - Legacy — semver-protected and unmaintained. Bugs in it are not fixed. Migrate rather than report.
- 0 - Deprecated — carries a DEP number and a migration.
What follows from the index:
- An experimental API can be removed with no deprecation cycle.
--experimental-transform-types shipped in 22.7.0
and was deleted in 26.0.0, taking the runtime path for TypeScript enums with it.
- Never put an experimental API in a published library's public surface. A consumer cannot see that the risk was
taken on their behalf.
- The index moves per major.
TracingChannel is Stable from 26.8.0 and experimental on 24; module.register() is
supported on 24 and runtime-deprecated on 26. State the index for the floor, not for the machine.
- Check the index rather than the flag. Type stripping needs no flag from 22.18.0 and was not Stable until 24.12.0;
an unflagged feature is not a committed one.
Module System
- Declare
"type" in every package.json. Without it, a .js or extensionless file is parsed as CommonJS and
re-parsed as an ES module when that fails. The retry costs startup time on every ES module and turns a real syntax
error into a confusing module-system error.
.mjs and .cjs override "type"; nothing overrides them. Use them to place one file of the other kind inside a
package, never as the default.
- Node's resolver performs no extension search and no directory-index lookup. It loads the specifier as written, so
./startup resolves in a bundler and throws ERR_MODULE_NOT_FOUND here.
- Import a builtin through
node: — import fs from 'node:fs/promises'. The prefix cannot be shadowed by a package
of the same name, and node:test and node:sqlite resolve under no other specifier.
import.meta.dirname and import.meta.filename replace __dirname and __filename, and exist only on file:
modules.
- The
assert import-attribute form was removed in 22.0.0 and throws. Only with parses.
require() of an ES module works from 20.19.0 and 22.12.0, and throws ERR_REQUIRE_ASYNC_MODULE when anything in
the graph uses top-level await. That is a property of the whole graph, so a transitive dependency can break a caller
that did not change.
- Reach for
module.registerHooks(), not module.register(). The latter is runtime-deprecated in 26.0.0 (DEP0205)
and needs --allow-worker under the Permission Model, because its hooks run on a worker thread.
Read [${CLAUDE_SKILL_DIR}/references/conventions/modules.md] when a specifier resolves in a bundler and not in Node,
when writing a loader hook, or when repeated start-up cost is worth caching — it carries what decides a file's module
system, the specifier rules, the two hook APIs, and the compile cache.
Package Manifest
"exports" seals the package. Once present, nothing outside the map is importable, deep relative paths included.
Add it deliberately: it is the only way to keep internals private, and adding it to a published package is a breaking
change.
- Condition order in the map is the match order. Earlier keys win, so write the most specific first.
"default" goes last and "types" first. "default" matches every caller, and "types" is a convention of the
type systems rather than a condition Node reads.
"import" and "require" describe the caller's syntax, not the target's format. A .mjs file behind "require"
is legal and loads through require(esm).
- Use
"module-sync" instead of a dual build where one ES module must serve both callers. Two builds of one package
give a process two class identities and two copies of every module-level singleton.
"imports" keys start with # and are the only path-alias mechanism that survives node file.ts, because
nothing in the runtime reads tsconfig.json.
- Every
"exports" target starts with ./. Absolute paths, file: URLs, ../, and any ., .., or
node_modules segment are rejected with ERR_INVALID_PACKAGE_TARGET.
Read [${CLAUDE_SKILL_DIR}/references/conventions/modules.md] when designing an "exports" map or publishing a package
that must serve both require and import — it carries the condition-matching rules, the target-path restrictions, and
the dual-package hazard.
TypeScript Execution
Node erases type syntax and runs the result. It does not compile, does not type-check, and does not read
tsconfig.json.
- Keep
tsc --noEmit in the verification path. Running a .ts file proves it parses, nothing more.
import type is mandatory for type-only imports. Erasure is syntactic: without the keyword the import survives as
a value import and fails at run time. verbatimModuleSyntax: true makes tsc enforce it.
- Write the real extension in the specifier —
import './x.ts', and require('./x.ts') in CommonJS. Node loads the
file that exists; it does not rewrite .js to .ts.
- Enums,
namespace with runtime code, parameter properties, and import aliases raise
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. --experimental-transform-types handled some of them and was removed in
26.0.0. Set erasableSyntaxOnly: true so the compiler rejects them first.
- A decorator fails as a bare
SyntaxError instead, with no code to match on — measured on 26.2.0. The parser
rejects the @ before type stripping runs.
- A dependency shipping
.ts files cannot be loaded. Node refuses TypeScript under any node_modules path,
deliberately and at every version.
Read [${CLAUDE_SKILL_DIR}/references/conventions/typescript-execution.md] when a .ts file fails to run, when
choosing between type stripping and a compiler, or when writing the tsconfig.json that matches the runtime — it
carries the flag history, the full refused-syntax list, and the matching compiler options.
Blocking
- No synchronous file, process, or crypto call on a request path.
readFileSync, execSync, pbkdf2Sync,
inflateSync belong in CLIs, build scripts, and startup code that runs before the server listens.
- Bound every input before parsing it.
JSON.parse is synchronous and scales with input size, so an unbounded body
is a denial of service that needs no bug.
- Never match a backtracking pattern against user input. Nested quantifiers, overlapping alternations, and
backreferences with repetition give one request the whole thread. Use
indexOf or a linear engine.
- Recursive
process.nextTick starves I/O. The tick queue drains to exhaustion before the loop advances, so a
self-scheduling nextTick stops the process serving traffic while looking idle. queueMicrotask has the same
property one level down.
setImmediate beats setTimeout(fn, 0) inside an I/O callback and races it at the top level. Never encode either
ordering as a dependency.
- Partition long synchronous loops with
setImmediate between chunks, or move them to a worker.
Read [${CLAUDE_SKILL_DIR}/references/conventions/concurrency.md] when a process stops serving traffic while looking
idle, or when one slow operation surfaces as an unrelated failure — it carries the loop phase order and the list of work
that shares the libuv thread pool.
Streams
- Compose with
pipeline from node:stream/promises. A .pipe() chain propagates neither errors nor destruction,
so one failure leaves the rest of the chain open with its descriptors held.
- Respect the return value of
write(). false means the buffer is over highWaterMark; keep writing and memory
grows without any error being raised. pipeline and for await handle this.
highWaterMark is a threshold, not a cap. A single write larger than it still buffers in full, and in object mode
the count is objects rather than bytes.
- Destroy a stream on the error path.
stream.destroy(err) releases the descriptor or socket; an undestroyed stream
leaks it.
- An unhandled
'error' event crashes the process. Every stream outside a pipeline needs a listener.
readable.map, filter, take, reduce and the rest are Experimental, on every major through 26. They read
like committed API and are not. Keep them out of a library's surface.
- Do not stream data already in memory. Streams earn their cost on unbounded or arriving-over-time input.
Read [${CLAUDE_SKILL_DIR}/references/conventions/streams.md] when crossing between Node streams and web streams, when
writing a custom Readable, Writable, or Transform, or when tuning highWaterMark — it carries the interop matrix,
the platform-dependent defaults, and the whole-stream consumers.
Errors and Exit
- Set
process.exitCode and let the loop drain. process.exit() terminates immediately, and a write to a pipe or a
socket may still be in flight — measured on 26.2.0/macOS, 512 KiB written to a pipe arrives as 64 KiB. A write to a
file is synchronous and survives, which is why the loss reads as intermittent.
- Match on
error.code, never on the message. Every built-in error carries one; messages change between minors.
util.inspect renders a cause chain, so an error chained with { cause: err } reaches a Node logger intact
without the caller unwrapping it.
- Never resume after
'uncaughtException'. Installing the listener disables the crash and leaves the process
running with unknown state. Log, flush, exit.
- An unhandled rejection is fatal by default. Do not add
--unhandled-rejections=warn to make a test suite pass.
'exit' listeners run synchronously only. Anything asynchronous scheduled there never runs.
- Cancel with
AbortSignal. fetch, fs/promises, timers/promises, pipeline, stream.addAbortSignal, and
events.on all take { signal }.
- A cancelled call does not report itself the same way twice. Node's own APIs reject with an
AbortError carrying
code: 'ABORT_ERR'; fetch rejects with a DOMException whose code is the number 20; and a fetch deadline
built from AbortSignal.timeout(ms) rejects with name === 'TimeoutError', not 'AbortError' — measured on 26.2.0.
Test signal.aborted where the signal is in scope, and err.name === 'AbortError' || err.name === 'TimeoutError'
where it is not.
- Set the HTTP server timeouts.
requestTimeout defaults to 300000 ms and keepAliveTimeout to 5000 ms; the latter
must be shorter than the idle timeout of any proxy in front, or the proxy reuses a socket Node has closed.
Read [${CLAUDE_SKILL_DIR}/references/conventions/lifecycle.md] when writing a graceful shutdown, when a process
refuses to exit or exits without flushing, or when auditing server timeouts — it carries the shutdown sequence, the
deprecation flags, and the timeout defaults.
Async Context and Diagnostics
run(store, fn) is the only Stable way to enter a store. enterWith and withScope are Experimental, and
enterWith applies for the rest of the current synchronous execution rather than for a scope — called inside one
event handler it leaks into every later handler on the same emit.
- Instrument with
diagnostics_channel, not with a logger call. Publishing to a channel with no subscriber costs
nothing measurable, so the instrumentation can ship enabled.
- Create a channel once at module top level and guard expensive payload construction with
channel.hasSubscribers.
- Subscribers receive the message object by reference. A subscriber that mutates it changes what the publisher and
later subscribers see.
- Measure loop lag with
perf_hooks.monitorEventLoopDelay(), not with a setTimeout drift estimate.
- Get a stack out of a native crash with
--report-on-fatalerror. No JavaScript handler runs at that point.
Read [${CLAUDE_SKILL_DIR}/references/conventions/observability.md] when context goes missing across an async boundary,
when adding tracing to a library, or when picking a profiling flag — it carries the context-loss diagnosis, the
TracingChannel contract, and the list of channels Node publishes itself.
HTTP Client
- Consume or cancel every response body, on the error path too. undici does not release the connection until the
body is read or cancelled, and the garbage collector is not prompt enough to save it — a pool exhausted this way
stalls rather than errors.
fetch does not reject on a non-2xx status. Check res.ok.
fetch has no total timeout. Pass signal: AbortSignal.timeout(ms), combined with the caller's signal through
AbortSignal.any.
http.Agent options do not reach fetch. It uses undici's global dispatcher; change it with a dispatcher
option per request or setGlobalDispatcher process-wide.
fetch ignores HTTP_PROXY unless told otherwise — NODE_USE_ENV_PROXY=1 or --use-env-proxy, from 22.21.0 and
24.5.0.
- Drop to
node:http for upgrades, CONNECT, trailers, or per-request agent control, and to node:http2 for
HTTP/2, which fetch does not speak.
Read [${CLAUDE_SKILL_DIR}/references/conventions/http-client.md] when tuning connection pooling, or when deciding
between fetch and node:http — it carries the dispatcher model and the undici version each Node major embeds.
Workers and Processes
- Workers are for CPU work. Node's asynchronous I/O beats a worker at I/O, and each worker carries a full V8
isolate.
- Size a pool from
os.availableParallelism(). os.cpus().length ignores CPU affinity and container limits.
--max-old-space-size is per isolate. Every worker adds its own heap, so the container limit must cover the sum.
- A worker receives no signals. The main thread must tell its workers to stop and wait for them, and
process.exit() inside a worker ends only that thread.
- A worker does not inherit the Permission Model.
process.env in a worker is a copy unless the Worker was constructed with env: SHARE_ENV.
- Never pass an argument array together with
{ shell: true }. spawn and execFile join it with spaces without
escaping, which is command injection — runtime-deprecated as DEP0190 in 24.0.0.
- Raise
UV_THREADPOOL_SIZE in the environment, not in the program. The pool is built during initialization, so
assigning process.env.UV_THREADPOOL_SIZE at run time does nothing.
Read [${CLAUDE_SKILL_DIR}/references/conventions/concurrency.md] when building a worker pool, or when choosing between
worker_threads, child_process, and cluster — it carries how data crosses a thread boundary and the full list of
behavior differences inside a worker.
Permission Model
--permission is a seat belt, not a sandbox. Node's own documentation states it does not protect against
malicious code. Never present it to a reviewer as containment for untrusted input.
- It does not restrict the network below 25.0.0.
--allow-net does not exist on the 22 and 24 lines, so a process
confined there still opens any socket it likes.
- Symlinks are followed out of the granted set. A relative link inside a granted directory reaches any path.
- An already-open file descriptor is never checked, including one inherited from the parent process.
- A granted directory that exists gets an implicit
/*; one that does not, does not. Write the wildcard explicitly
when the directory is created at run time.
- Derive the grant list with
--permission-audit (24.20.0) before enforcing. It publishes every violation to a
node:permission-model:* channel and denies nothing.
Read [${CLAUDE_SKILL_DIR}/references/conventions/permissions.md] when confining a process, when a run fails with
ERR_ACCESS_DENIED, or when writing the grant list — it carries the full scope-to-flag map, the path-matching rules,
and everything the model does not cover.
Supply Chain
npm ci in CI and in image builds. It requires a lockfile, removes node_modules first, and fails rather than
rewriting the lockfile when it disagrees with package.json.
- Set
ignore-scripts=true in .npmrc. Lifecycle scripts run for the whole dependency tree with the runner's
privileges, and this is where almost all registry compromise lands. Re-enable it per package where a build genuinely
needs it.
- Set
min-release-age to a window in days (npm 11.10.0). A compromised-account attack publishes a version that is
minutes old, and the window is what blocks it. When no version of a dependency clears the window the command errors,
so treat that exit as a signal rather than a reason to remove the setting. The min-release-age-exclude escape hatch
needs npm 12.0.0, which no Node line bundles.
- Publish from CI over OIDC trusted publishing, not with a token. npm classic tokens were revoked on 2025-12-09, and
a granular token with
Bypass 2FA set overrides both account-level and package-level 2FA for publishing.
- Node 22 ships npm 10, so neither
min-release-age nor trusted publishing works on that line without upgrading
npm.
- Corepack stopped shipping with Node 25, so
"packageManager" is inert unless corepack is installed separately.
Read [${CLAUDE_SKILL_DIR}/references/conventions/supply-chain.md] when hardening an install, configuring publishing,
or auditing what a lockfile actually guarantees — it carries the trusted-publishing constraints, the provenance rules,
and the npm configuration keys.
CLI and Flags
node --run <script> is not npm run. It skips pre and post scripts, sets no npm_package_* variables, and
does not pass --env-file variables to the command.
--env-file is read before the Permission Model initializes, so --permission cannot restrict which file it
reads, and the file can set NODE_OPTIONS.
- The real environment overrides an
--env-file value. Later --env-file flags override earlier ones.
NODE_OPTIONS refuses -e and -p. Node exits with -e is not allowed in NODE_OPTIONS rather than ignoring the
flag.
- A bare script path in
NODE_OPTIONS is dropped silently — the file never runs and nothing warns, measured on
26.2.0. Load code from the variable with --require or --import.
--test defaults to process isolation, one child per file, and reports asynchronous work that outlives a test as
a failure rather than dropping it.
--test picks up .ts files under the same name patterns unless --no-strip-types is passed.
Read [${CLAUDE_SKILL_DIR}/references/conventions/cli-and-flags.md] when wiring a container entrypoint, building a
single executable, or choosing hardening flags — it carries the environment-file rules, the test-runner CLI surface, the
SEA build paths per major, and --disable-proto, --frozen-intrinsics, and --secure-heap.
Testing with node:test
test() and suite() are the names; it() and describe() are aliases. Subtests come from the context —
await t.test(...) — and beforeEach/afterEach fire between subtests, not only between top-level tests.
- Await every
t.test(). The runner awaits subtests from 24.0.0, but the 22 line does not, and the await is what
makes ordering deterministic on both.
- Assert through
t.assert.*, not the bare node:assert. Only the context-bound form is counted by t.plan(), so
a bare assert leaves the plan short and the failure reads as a miscount rather than a wrong assertion.
t.plan(n) checks the moment the test function returns. The default is { wait: false }; an assertion arriving
from a callback afterwards is not counted. Pass { wait: true } or a millisecond budget.
- Import
node:assert/strict, never node:assert, outside a test. The legacy surface compares with ==, so
assert.equal(1, '1') passes. assert.CallTracker was removed in 25.0.0 (DEP0173) — use mock.fn() and
fn.mock.callCount().
- Prefer
t.mock to the imported mock. The context tracker is reset after each test; the module-level one is
process-wide and needs a manual mock.reset() or mock.restoreAll().
mock.module() needs --experimental-test-module-mocks. Without the flag it is undefined, so the test dies on
a TypeError instead of a clear message. It is Stability 1.0, the least settled part of the runner.
- A destructured timer import is never mocked.
import { setTimeout } from 'node:timers' binds the real function
before mock.timers.enable() runs. Call the global or the namespace property.
- Pass
--test-reporter explicitly. The non-TTY default changed from tap to spec in 23.0.0, so a piped CI run
emits a different format on 22 than on 24. Reporter output is not a stable contract — parse the TestsStream events
instead.
- Coverage is experimental and off:
--experimental-test-coverage, with core modules, node_modules/, and the test
files excluded by default.
- Never run
--test-update-snapshots in CI. It rewrites the baseline, turning every regression into a pass.
--test-force-exit hides an open handle rather than closing one. Find it with process.getActiveResourcesInfo().
Read [${CLAUDE_SKILL_DIR}/references/conventions/testing.md] when writing the first test in a project, when a mock or
a fake timer does not take effect, or when wiring reporters and coverage thresholds — it carries the structure and hook
rules, the assertion surface, the mocking API with its restore semantics and flags, snapshots, the reporter pairing
rules, and the subtest-awaiting behavior that the shipped documentation states incorrectly.
Application
When writing Node.js code, apply these rules silently — do not narrate a rule while following it. Where existing
code contradicts one, follow the codebase and flag the divergence once.
When reviewing Node.js code, cite the violation and show the fix inline. Do not lecture.
Bad: "Node.js best practice is to avoid process.exit() because stdout writes..."
Good: process.exit(1) -> process.exitCode = 1
Integration
The javascript and typescript skills own the language and the type system; this skill owns the runtime that
executes them. The split with javascript is resolution against syntax: javascript states what an ES module means,
this skill states how Node finds it, what package.json changes about that, and which of Node's own APIs accept a
signal. typescript states what each compiler option does; this skill states what Node erases and refuses.
node:test is this skill's, bun:test is the bun skill's, and the vitest skill owns Vitest. Which one a
project adopts is a project decision that no skill states. The coding skill governs workflow.
Node enforces almost nothing it lets you declare — the floor, the permission grant, and the stability index each hold
by discipline, and the runtime reports no violation of any of them.
1---2name: nodejs3description: Write and review Node.js: the module system, package manifest, streams, event loop, process and worker lifecycle, the permission model, native TypeScript execution, `node:test`, and the runtime version `engines.node` permits.4---56One thread runs every callback in a Node process, so anything that holds it holds the whole program. Three biases decide7most calls:89- **The `engines.node` floor decides what may be written**, not the `node` binary on the machine.10- **Read an API's stability index before depending on it.** Node ships an API for years before committing to it, and an11 experimental one can be deleted without a deprecation cycle.12- **Check the standard library before adding a dependency.** It absorbed `fetch`, a test runner, a file watcher, `glob`,13 SQLite, `.env` parsing, a script runner, and TypeScript execution.1415## Node Version1617`engines.node` in `package.json` is the floor a project claims. Read it before writing code and never reach for an API18above it. Nothing enforces it at run time — Node never reads the field, and npm checks it only under19`engine-strict=true` — so the floor holds by discipline and by CI running the oldest major it names.2021**The installed binary is not the floor.** `node --version` reports what one machine has, not what the project supports.2223Support windows, which decide whether a floor is defensible:2425- **20** — end of life 2026-04-30. A floor of `>=20` claims a runtime that receives no fixes at all.26- **22** — maintenance from 2025-10-21, end of life 2027-04-30. Security and critical fixes only; no new capability.27- **24** — Active LTS from 2025-10-28, maintenance from 2026-10-20, end of life 2028-04-30.28- **26** — released 2026-05-05, Active LTS from 2026-10-28, end of life 2029-04-30.2930Floor version per feature — the APIs and behavior changes that decide what may be written.3132- **20.19.0** — `require(esm)` unflagged on the 20 line; syntax detection on by default33- **22.0.0** — `node --run`; `fs.glob`; `WebSocket` global; watch mode Stable; `assert { type: 'json' }` removed34- **22.12.0** — `require(esm)` unflagged on the 22 line; JSON modules Stable35- **22.13.0** — Permission Model Stable and `--permission` accepted; `node:sqlite` unflagged36- **22.15.0** — `module.registerHooks()`37- **22.16.0** — `import.meta.dirname` and `import.meta.filename` Stable; `--experimental-config-file`38- **22.18.0** — type stripping on by default; `import.meta.main`39- **22.21.0** — `--env-file` Stable; `NODE_USE_ENV_PROXY`40- **24.0.0** — `--experimental-permission` renamed `--permission`; `AsyncLocalStorage` on `AsyncContextFrame`;41 `URLPattern` global; test runner awaits subtests; `url.parse()` runtime-deprecated (DEP0169); `args` with42 `{ shell: true }` runtime-deprecated (DEP0190)43- **24.12.0** — type stripping Stable44- **24.20.0** — `--permission-audit`45- **26.0.0** — `Temporal` global; `module.register()` runtime-deprecated (DEP0205); `--experimental-transform-types`46 removed; `node:_stream_*` removed. It also inherits what 25 added and neither 22 nor 24 received: `--allow-net`, and47 with it network confinement under `--permission`48- **26.3.0** — `permission.drop()`4950**No odd major is a defensible floor.** Node 21, 23, and 25 are end of life — 25 since 2026-06-01 — so a feature that51arrived on one is reachable only from the LTS major above it.5253Read [`${CLAUDE_SKILL_DIR}/references/versions/node-NN.md`] — one file per LTS major: `node-20.md`, `node-22.md`,54`node-24.md`, `node-26.md` — when writing against a feature near the floor, and whenever raising the floor. Each carries55what its major added, the stability each addition holds, the behavior it changed, and the traps it introduced.5657## Stability Index5859Every documented API carries one, and it decides how the API may be used, not merely how finished it is.6061- **2 - Stable** — protected by semver. Safe in a published library's surface.62- **1 - Experimental** — outside semver, and the level most unfinished APIs print. Three subdivisions refine it: **1.2 -63 Release candidate**, where no further breaking change is anticipated but none is ruled out; **1.1 - Active64 development**, nearing minimum viability; **1.0 - Early development**, unfinished and expecting substantial change.65- **3 - Legacy** — semver-protected and unmaintained. Bugs in it are not fixed. Migrate rather than report.66- **0 - Deprecated** — carries a DEP number and a migration.6768What follows from the index:6970- **An experimental API can be removed with no deprecation cycle.** `--experimental-transform-types` shipped in 22.7.071 and was deleted in 26.0.0, taking the runtime path for TypeScript enums with it.72- **Never put an experimental API in a published library's public surface.** A consumer cannot see that the risk was73 taken on their behalf.74- **The index moves per major.** `TracingChannel` is Stable from 26.8.0 and experimental on 24; `module.register()` is75 supported on 24 and runtime-deprecated on 26. State the index for the floor, not for the machine.76- **Check the index rather than the flag.** Type stripping needs no flag from 22.18.0 and was not Stable until 24.12.0;77 an unflagged feature is not a committed one.7879## Module System8081- **Declare `"type"` in every `package.json`.** Without it, a `.js` or extensionless file is parsed as CommonJS and82 re-parsed as an ES module when that fails. The retry costs startup time on every ES module and turns a real syntax83 error into a confusing module-system error.84- **`.mjs` and `.cjs` override `"type"`; nothing overrides them.** Use them to place one file of the other kind inside a85 package, never as the default.86- **Node's resolver performs no extension search and no directory-index lookup.** It loads the specifier as written, so87 `./startup` resolves in a bundler and throws `ERR_MODULE_NOT_FOUND` here.88- **Import a builtin through `node:`** — `import fs from 'node:fs/promises'`. The prefix cannot be shadowed by a package89 of the same name, and `node:test` and `node:sqlite` resolve under no other specifier.90- **`import.meta.dirname` and `import.meta.filename` replace `__dirname` and `__filename`**, and exist only on `file:`91 modules.92- **The `assert` import-attribute form was removed in 22.0.0 and throws.** Only `with` parses.93- **`require()` of an ES module works** from 20.19.0 and 22.12.0, and throws `ERR_REQUIRE_ASYNC_MODULE` when anything in94 the graph uses top-level `await`. That is a property of the whole graph, so a transitive dependency can break a caller95 that did not change.96- **Reach for `module.registerHooks()`, not `module.register()`.** The latter is runtime-deprecated in 26.0.0 (DEP0205)97 and needs `--allow-worker` under the Permission Model, because its hooks run on a worker thread.9899Read [`${CLAUDE_SKILL_DIR}/references/conventions/modules.md`] when a specifier resolves in a bundler and not in Node,100when writing a loader hook, or when repeated start-up cost is worth caching — it carries what decides a file's module101system, the specifier rules, the two hook APIs, and the compile cache.102103## Package Manifest104105- **`"exports"` seals the package.** Once present, nothing outside the map is importable, deep relative paths included.106 Add it deliberately: it is the only way to keep internals private, and adding it to a published package is a breaking107 change.108- **Condition order in the map is the match order.** Earlier keys win, so write the most specific first.109- **`"default"` goes last and `"types"` first.** `"default"` matches every caller, and `"types"` is a convention of the110 type systems rather than a condition Node reads.111- **`"import"` and `"require"` describe the caller's syntax, not the target's format.** A `.mjs` file behind `"require"`112 is legal and loads through `require(esm)`.113- **Use `"module-sync"` instead of a dual build** where one ES module must serve both callers. Two builds of one package114 give a process two class identities and two copies of every module-level singleton.115- **`"imports"` keys start with `#`** and are the only path-alias mechanism that survives `node file.ts`, because116 nothing in the runtime reads `tsconfig.json`.117- **Every `"exports"` target starts with `./`.** Absolute paths, `file:` URLs, `../`, and any `.`, `..`, or118 `node_modules` segment are rejected with `ERR_INVALID_PACKAGE_TARGET`.119120Read [`${CLAUDE_SKILL_DIR}/references/conventions/modules.md`] when designing an `"exports"` map or publishing a package121that must serve both `require` and `import` — it carries the condition-matching rules, the target-path restrictions, and122the dual-package hazard.123124## TypeScript Execution125126Node erases type syntax and runs the result. It does not compile, does not type-check, and does not read127`tsconfig.json`.128129- **Keep `tsc --noEmit` in the verification path.** Running a `.ts` file proves it parses, nothing more.130- **`import type` is mandatory for type-only imports.** Erasure is syntactic: without the keyword the import survives as131 a value import and fails at run time. `verbatimModuleSyntax: true` makes `tsc` enforce it.132- **Write the real extension in the specifier** — `import './x.ts'`, and `require('./x.ts')` in CommonJS. Node loads the133 file that exists; it does not rewrite `.js` to `.ts`.134- **Enums, `namespace` with runtime code, parameter properties, and import aliases raise135 `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`.** `--experimental-transform-types` handled some of them and was removed in136 26.0.0. Set `erasableSyntaxOnly: true` so the compiler rejects them first.137- **A decorator fails as a bare `SyntaxError` instead**, with no `code` to match on — measured on 26.2.0. The parser138 rejects the `@` before type stripping runs.139- **A dependency shipping `.ts` files cannot be loaded.** Node refuses TypeScript under any `node_modules` path,140 deliberately and at every version.141142Read [`${CLAUDE_SKILL_DIR}/references/conventions/typescript-execution.md`] when a `.ts` file fails to run, when143choosing between type stripping and a compiler, or when writing the `tsconfig.json` that matches the runtime — it144carries the flag history, the full refused-syntax list, and the matching compiler options.145146## Blocking147148- **No synchronous file, process, or crypto call on a request path.** `readFileSync`, `execSync`, `pbkdf2Sync`,149 `inflateSync` belong in CLIs, build scripts, and startup code that runs before the server listens.150- **Bound every input before parsing it.** `JSON.parse` is synchronous and scales with input size, so an unbounded body151 is a denial of service that needs no bug.152- **Never match a backtracking pattern against user input.** Nested quantifiers, overlapping alternations, and153 backreferences with repetition give one request the whole thread. Use `indexOf` or a linear engine.154- **Recursive `process.nextTick` starves I/O.** The tick queue drains to exhaustion before the loop advances, so a155 self-scheduling `nextTick` stops the process serving traffic while looking idle. `queueMicrotask` has the same156 property one level down.157- **`setImmediate` beats `setTimeout(fn, 0)` inside an I/O callback and races it at the top level.** Never encode either158 ordering as a dependency.159- **Partition long synchronous loops** with `setImmediate` between chunks, or move them to a worker.160161Read [`${CLAUDE_SKILL_DIR}/references/conventions/concurrency.md`] when a process stops serving traffic while looking162idle, or when one slow operation surfaces as an unrelated failure — it carries the loop phase order and the list of work163that shares the libuv thread pool.164165## Streams166167- **Compose with `pipeline` from `node:stream/promises`.** A `.pipe()` chain propagates neither errors nor destruction,168 so one failure leaves the rest of the chain open with its descriptors held.169- **Respect the return value of `write()`.** `false` means the buffer is over `highWaterMark`; keep writing and memory170 grows without any error being raised. `pipeline` and `for await` handle this.171- **`highWaterMark` is a threshold, not a cap.** A single write larger than it still buffers in full, and in object mode172 the count is objects rather than bytes.173- **Destroy a stream on the error path.** `stream.destroy(err)` releases the descriptor or socket; an undestroyed stream174 leaks it.175- **An unhandled `'error'` event crashes the process.** Every stream outside a `pipeline` needs a listener.176- **`readable.map`, `filter`, `take`, `reduce` and the rest are Experimental**, on every major through 26. They read177 like committed API and are not. Keep them out of a library's surface.178- **Do not stream data already in memory.** Streams earn their cost on unbounded or arriving-over-time input.179180Read [`${CLAUDE_SKILL_DIR}/references/conventions/streams.md`] when crossing between Node streams and web streams, when181writing a custom `Readable`, `Writable`, or `Transform`, or when tuning `highWaterMark` — it carries the interop matrix,182the platform-dependent defaults, and the whole-stream consumers.183184## Errors and Exit185186- **Set `process.exitCode` and let the loop drain.** `process.exit()` terminates immediately, and a write to a pipe or a187 socket may still be in flight — measured on 26.2.0/macOS, 512 KiB written to a pipe arrives as 64 KiB. A write to a188 file is synchronous and survives, which is why the loss reads as intermittent.189- **Match on `error.code`, never on the message.** Every built-in error carries one; messages change between minors.190- **`util.inspect` renders a `cause` chain**, so an error chained with `{ cause: err }` reaches a Node logger intact191 without the caller unwrapping it.192- **Never resume after `'uncaughtException'`.** Installing the listener disables the crash and leaves the process193 running with unknown state. Log, flush, exit.194- **An unhandled rejection is fatal by default.** Do not add `--unhandled-rejections=warn` to make a test suite pass.195- **`'exit'` listeners run synchronously only.** Anything asynchronous scheduled there never runs.196- **Cancel with `AbortSignal`.** `fetch`, `fs/promises`, `timers/promises`, `pipeline`, `stream.addAbortSignal`, and197 `events.on` all take `{ signal }`.198- **A cancelled call does not report itself the same way twice.** Node's own APIs reject with an `AbortError` carrying199 `code: 'ABORT_ERR'`; `fetch` rejects with a `DOMException` whose `code` is the number `20`; and a `fetch` deadline200 built from `AbortSignal.timeout(ms)` rejects with `name === 'TimeoutError'`, not `'AbortError'` — measured on 26.2.0.201 Test `signal.aborted` where the signal is in scope, and `err.name === 'AbortError' || err.name === 'TimeoutError'`202 where it is not.203- **Set the HTTP server timeouts.** `requestTimeout` defaults to 300000 ms and `keepAliveTimeout` to 5000 ms; the latter204 must be shorter than the idle timeout of any proxy in front, or the proxy reuses a socket Node has closed.205206Read [`${CLAUDE_SKILL_DIR}/references/conventions/lifecycle.md`] when writing a graceful shutdown, when a process207refuses to exit or exits without flushing, or when auditing server timeouts — it carries the shutdown sequence, the208deprecation flags, and the timeout defaults.209210## Async Context and Diagnostics211212- **`run(store, fn)` is the only Stable way to enter a store.** `enterWith` and `withScope` are Experimental, and213 `enterWith` applies for the rest of the current synchronous execution rather than for a scope — called inside one214 event handler it leaks into every later handler on the same emit.215- **Instrument with `diagnostics_channel`, not with a logger call.** Publishing to a channel with no subscriber costs216 nothing measurable, so the instrumentation can ship enabled.217- **Create a channel once at module top level** and guard expensive payload construction with `channel.hasSubscribers`.218- **Subscribers receive the message object by reference.** A subscriber that mutates it changes what the publisher and219 later subscribers see.220- **Measure loop lag with `perf_hooks.monitorEventLoopDelay()`**, not with a `setTimeout` drift estimate.221- **Get a stack out of a native crash with `--report-on-fatalerror`.** No JavaScript handler runs at that point.222223Read [`${CLAUDE_SKILL_DIR}/references/conventions/observability.md`] when context goes missing across an async boundary,224when adding tracing to a library, or when picking a profiling flag — it carries the context-loss diagnosis, the225`TracingChannel` contract, and the list of channels Node publishes itself.226227## HTTP Client228229- **Consume or cancel every response body**, on the error path too. undici does not release the connection until the230 body is read or cancelled, and the garbage collector is not prompt enough to save it — a pool exhausted this way231 stalls rather than errors.232- **`fetch` does not reject on a non-2xx status.** Check `res.ok`.233- **`fetch` has no total timeout.** Pass `signal: AbortSignal.timeout(ms)`, combined with the caller's signal through234 `AbortSignal.any`.235- **`http.Agent` options do not reach `fetch`.** It uses undici's global dispatcher; change it with a `dispatcher`236 option per request or `setGlobalDispatcher` process-wide.237- **`fetch` ignores `HTTP_PROXY` unless told otherwise** — `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, from 22.21.0 and238 24.5.0.239- **Drop to `node:http` for upgrades, `CONNECT`, trailers, or per-request agent control**, and to `node:http2` for240 HTTP/2, which `fetch` does not speak.241242Read [`${CLAUDE_SKILL_DIR}/references/conventions/http-client.md`] when tuning connection pooling, or when deciding243between `fetch` and `node:http` — it carries the dispatcher model and the undici version each Node major embeds.244245## Workers and Processes246247- **Workers are for CPU work.** Node's asynchronous I/O beats a worker at I/O, and each worker carries a full V8248 isolate.249- **Size a pool from `os.availableParallelism()`.** `os.cpus().length` ignores CPU affinity and container limits.250- **`--max-old-space-size` is per isolate.** Every worker adds its own heap, so the container limit must cover the sum.251- **A worker receives no signals.** The main thread must tell its workers to stop and wait for them, and252 `process.exit()` inside a worker ends only that thread.253- **A worker does not inherit the Permission Model.**254- **`process.env` in a worker is a copy** unless the `Worker` was constructed with `env: SHARE_ENV`.255- **Never pass an argument array together with `{ shell: true }`.** `spawn` and `execFile` join it with spaces without256 escaping, which is command injection — runtime-deprecated as DEP0190 in 24.0.0.257- **Raise `UV_THREADPOOL_SIZE` in the environment, not in the program.** The pool is built during initialization, so258 assigning `process.env.UV_THREADPOOL_SIZE` at run time does nothing.259260Read [`${CLAUDE_SKILL_DIR}/references/conventions/concurrency.md`] when building a worker pool, or when choosing between261`worker_threads`, `child_process`, and `cluster` — it carries how data crosses a thread boundary and the full list of262behavior differences inside a worker.263264## Permission Model265266- **`--permission` is a seat belt, not a sandbox.** Node's own documentation states it does not protect against267 malicious code. Never present it to a reviewer as containment for untrusted input.268- **It does not restrict the network below 25.0.0.** `--allow-net` does not exist on the 22 and 24 lines, so a process269 confined there still opens any socket it likes.270- **Symlinks are followed out of the granted set.** A relative link inside a granted directory reaches any path.271- **An already-open file descriptor is never checked**, including one inherited from the parent process.272- **A granted directory that exists gets an implicit `/*`; one that does not, does not.** Write the wildcard explicitly273 when the directory is created at run time.274- **Derive the grant list with `--permission-audit`** (24.20.0) before enforcing. It publishes every violation to a275 `node:permission-model:*` channel and denies nothing.276277Read [`${CLAUDE_SKILL_DIR}/references/conventions/permissions.md`] when confining a process, when a run fails with278`ERR_ACCESS_DENIED`, or when writing the grant list — it carries the full scope-to-flag map, the path-matching rules,279and everything the model does not cover.280281## Supply Chain282283- **`npm ci` in CI and in image builds.** It requires a lockfile, removes `node_modules` first, and fails rather than284 rewriting the lockfile when it disagrees with `package.json`.285- **Set `ignore-scripts=true` in `.npmrc`.** Lifecycle scripts run for the whole dependency tree with the runner's286 privileges, and this is where almost all registry compromise lands. Re-enable it per package where a build genuinely287 needs it.288- **Set `min-release-age` to a window in days** (npm 11.10.0). A compromised-account attack publishes a version that is289 minutes old, and the window is what blocks it. When no version of a dependency clears the window the command errors,290 so treat that exit as a signal rather than a reason to remove the setting. The `min-release-age-exclude` escape hatch291 needs npm 12.0.0, which no Node line bundles.292- **Publish from CI over OIDC trusted publishing**, not with a token. npm classic tokens were revoked on 2025-12-09, and293 a granular token with `Bypass 2FA` set overrides both account-level and package-level 2FA for publishing.294- **Node 22 ships npm 10**, so neither `min-release-age` nor trusted publishing works on that line without upgrading295 npm.296- **Corepack stopped shipping with Node 25**, so `"packageManager"` is inert unless `corepack` is installed separately.297298Read [`${CLAUDE_SKILL_DIR}/references/conventions/supply-chain.md`] when hardening an install, configuring publishing,299or auditing what a lockfile actually guarantees — it carries the trusted-publishing constraints, the provenance rules,300and the npm configuration keys.301302## CLI and Flags303304- **`node --run <script>` is not `npm run`.** It skips `pre` and `post` scripts, sets no `npm_package_*` variables, and305 does not pass `--env-file` variables to the command.306- **`--env-file` is read before the Permission Model initializes**, so `--permission` cannot restrict which file it307 reads, and the file can set `NODE_OPTIONS`.308- **The real environment overrides an `--env-file` value.** Later `--env-file` flags override earlier ones.309- **`NODE_OPTIONS` refuses `-e` and `-p`.** Node exits with `-e is not allowed in NODE_OPTIONS` rather than ignoring the310 flag.311- **A bare script path in `NODE_OPTIONS` is dropped silently** — the file never runs and nothing warns, measured on312 26.2.0. Load code from the variable with `--require` or `--import`.313- **`--test` defaults to process isolation**, one child per file, and reports asynchronous work that outlives a test as314 a failure rather than dropping it.315- **`--test` picks up `.ts` files** under the same name patterns unless `--no-strip-types` is passed.316317Read [`${CLAUDE_SKILL_DIR}/references/conventions/cli-and-flags.md`] when wiring a container entrypoint, building a318single executable, or choosing hardening flags — it carries the environment-file rules, the test-runner CLI surface, the319SEA build paths per major, and `--disable-proto`, `--frozen-intrinsics`, and `--secure-heap`.320321## Testing with `node:test`322323- **`test()` and `suite()` are the names; `it()` and `describe()` are aliases.** Subtests come from the context —324 `await t.test(...)` — and `beforeEach`/`afterEach` fire between subtests, not only between top-level tests.325- **Await every `t.test()`.** The runner awaits subtests from 24.0.0, but the 22 line does not, and the await is what326 makes ordering deterministic on both.327- **Assert through `t.assert.*`, not the bare `node:assert`.** Only the context-bound form is counted by `t.plan()`, so328 a bare `assert` leaves the plan short and the failure reads as a miscount rather than a wrong assertion.329- **`t.plan(n)` checks the moment the test function returns.** The default is `{ wait: false }`; an assertion arriving330 from a callback afterwards is not counted. Pass `{ wait: true }` or a millisecond budget.331- **Import `node:assert/strict`, never `node:assert`, outside a test.** The legacy surface compares with `==`, so332 `assert.equal(1, '1')` passes. `assert.CallTracker` was removed in 25.0.0 (DEP0173) — use `mock.fn()` and333 `fn.mock.callCount()`.334- **Prefer `t.mock` to the imported `mock`.** The context tracker is reset after each test; the module-level one is335 process-wide and needs a manual `mock.reset()` or `mock.restoreAll()`.336- **`mock.module()` needs `--experimental-test-module-mocks`.** Without the flag it is `undefined`, so the test dies on337 a `TypeError` instead of a clear message. It is Stability 1.0, the least settled part of the runner.338- **A destructured timer import is never mocked.** `import { setTimeout } from 'node:timers'` binds the real function339 before `mock.timers.enable()` runs. Call the global or the namespace property.340- **Pass `--test-reporter` explicitly.** The non-TTY default changed from `tap` to `spec` in 23.0.0, so a piped CI run341 emits a different format on 22 than on 24. Reporter output is not a stable contract — parse the `TestsStream` events342 instead.343- **Coverage is experimental and off**: `--experimental-test-coverage`, with core modules, `node_modules/`, and the test344 files excluded by default.345- **Never run `--test-update-snapshots` in CI.** It rewrites the baseline, turning every regression into a pass.346- **`--test-force-exit` hides an open handle rather than closing one.** Find it with `process.getActiveResourcesInfo()`.347348Read [`${CLAUDE_SKILL_DIR}/references/conventions/testing.md`] when writing the first test in a project, when a mock or349a fake timer does not take effect, or when wiring reporters and coverage thresholds — it carries the structure and hook350rules, the assertion surface, the mocking API with its restore semantics and flags, snapshots, the reporter pairing351rules, and the subtest-awaiting behavior that the shipped documentation states incorrectly.352353## Application354355When **writing** Node.js code, apply these rules silently — do not narrate a rule while following it. Where existing356code contradicts one, follow the codebase and flag the divergence once.357358When **reviewing** Node.js code, cite the violation and show the fix inline. Do not lecture.359360```361Bad: "Node.js best practice is to avoid process.exit() because stdout writes..."362Good: process.exit(1) -> process.exitCode = 1363```364365## Integration366367The **javascript** and **typescript** skills own the language and the type system; this skill owns the runtime that368executes them. The split with `javascript` is resolution against syntax: `javascript` states what an ES module means,369this skill states how Node finds it, what `package.json` changes about that, and which of Node's own APIs accept a370signal. `typescript` states what each compiler option does; this skill states what Node erases and refuses.371372`node:test` is this skill's, `bun:test` is the **bun** skill's, and the **vitest** skill owns Vitest. Which one a373project adopts is a project decision that no skill states. The **coding** skill governs workflow.374375**Node enforces almost nothing it lets you declare — the floor, the permission grant, and the stability index each hold376by discipline, and the runtime reports no violation of any of them.**