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.nodefloor decides what may be written, not thenodebinary 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,.envparsing, 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
>=20claims 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;WebSocketglobal; 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
--permissionaccepted;node:sqliteunflagged - 22.15.0 —
module.registerHooks() - 22.16.0 —
import.meta.dirnameandimport.meta.filenameStable;--experimental-config-file - 22.18.0 — type stripping on by default;
import.meta.main - 22.21.0 —
--env-fileStable;NODE_USE_ENV_PROXY - 24.0.0 —
--experimental-permissionrenamed--permission;AsyncLocalStorageonAsyncContextFrame;URLPatternglobal; test runner awaits subtests;url.parse()runtime-deprecated (DEP0169);argswith{ shell: true }runtime-deprecated (DEP0190) - 24.12.0 — type stripping Stable
- 24.20.0 —
--permission-audit - 26.0.0 —
Temporalglobal;module.register()runtime-deprecated (DEP0205);--experimental-transform-typesremoved;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-typesshipped 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.
TracingChannelis 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 everypackage.json. Without it, a.jsor 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. .mjsand.cjsoverride"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
./startupresolves in a bundler and throwsERR_MODULE_NOT_FOUNDhere. - Import a builtin through
node:—import fs from 'node:fs/promises'. The prefix cannot be shadowed by a package of the same name, andnode:testandnode:sqliteresolve under no other specifier. import.meta.dirnameandimport.meta.filenamereplace__dirnameand__filename, and exist only onfile:modules.- The
assertimport-attribute form was removed in 22.0.0 and throws. Onlywithparses. require()of an ES module works from 20.19.0 and 22.12.0, and throwsERR_REQUIRE_ASYNC_MODULEwhen anything in the graph uses top-levelawait. That is a property of the whole graph, so a transitive dependency can break a caller that did not change.- Reach for
module.registerHooks(), notmodule.register(). The latter is runtime-deprecated in 26.0.0 (DEP0205) and needs--allow-workerunder 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.mjsfile behind"require"is legal and loads throughrequire(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 survivesnode file.ts, because nothing in the runtime readstsconfig.json.- Every
"exports"target starts with./. Absolute paths,file:URLs,../, and any.,.., ornode_modulessegment are rejected withERR_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 --noEmitin the verification path. Running a.tsfile proves it parses, nothing more. import typeis mandatory for type-only imports. Erasure is syntactic: without the keyword the import survives as a value import and fails at run time.verbatimModuleSyntax: truemakestscenforce it.- Write the real extension in the specifier —
import './x.ts', andrequire('./x.ts')in CommonJS. Node loads the file that exists; it does not rewrite.jsto.ts. - Enums,
namespacewith runtime code, parameter properties, and import aliases raiseERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.--experimental-transform-typeshandled some of them and was removed in 26.0.0. SeterasableSyntaxOnly: trueso the compiler rejects them first. - A decorator fails as a bare
SyntaxErrorinstead, with nocodeto match on — measured on 26.2.0. The parser rejects the@before type stripping runs. - A dependency shipping
.tsfiles cannot be loaded. Node refuses TypeScript under anynode_modulespath, 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,inflateSyncbelong in CLIs, build scripts, and startup code that runs before the server listens. - Bound every input before parsing it.
JSON.parseis 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
indexOfor a linear engine. - Recursive
process.nextTickstarves I/O. The tick queue drains to exhaustion before the loop advances, so a self-schedulingnextTickstops the process serving traffic while looking idle.queueMicrotaskhas the same property one level down. setImmediatebeatssetTimeout(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
setImmediatebetween 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
pipelinefromnode: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().falsemeans the buffer is overhighWaterMark; keep writing and memory grows without any error being raised.pipelineandfor awaithandle this. highWaterMarkis 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 apipelineneeds a listener. readable.map,filter,take,reduceand 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.exitCodeand 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.inspectrenders acausechain, 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=warnto 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, andevents.onall take{ signal }. - A cancelled call does not report itself the same way twice. Node's own APIs reject with an
AbortErrorcarryingcode: 'ABORT_ERR';fetchrejects with aDOMExceptionwhosecodeis the number20; and afetchdeadline built fromAbortSignal.timeout(ms)rejects withname === 'TimeoutError', not'AbortError'— measured on 26.2.0. Testsignal.abortedwhere the signal is in scope, anderr.name === 'AbortError' || err.name === 'TimeoutError'where it is not. - Set the HTTP server timeouts.
requestTimeoutdefaults to 300000 ms andkeepAliveTimeoutto 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.enterWithandwithScopeare Experimental, andenterWithapplies 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 asetTimeoutdrift 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.
fetchdoes not reject on a non-2xx status. Checkres.ok.fetchhas no total timeout. Passsignal: AbortSignal.timeout(ms), combined with the caller's signal throughAbortSignal.any.http.Agentoptions do not reachfetch. It uses undici's global dispatcher; change it with adispatcheroption per request orsetGlobalDispatcherprocess-wide.fetchignoresHTTP_PROXYunless told otherwise —NODE_USE_ENV_PROXY=1or--use-env-proxy, from 22.21.0 and 24.5.0.- Drop to
node:httpfor upgrades,CONNECT, trailers, or per-request agent control, and tonode:http2for HTTP/2, whichfetchdoes 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().lengthignores CPU affinity and container limits. --max-old-space-sizeis 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.envin a worker is a copy unless theWorkerwas constructed withenv: SHARE_ENV.- Never pass an argument array together with
{ shell: true }.spawnandexecFilejoin it with spaces without escaping, which is command injection — runtime-deprecated as DEP0190 in 24.0.0. - Raise
UV_THREADPOOL_SIZEin the environment, not in the program. The pool is built during initialization, so assigningprocess.env.UV_THREADPOOL_SIZEat 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
--permissionis 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-netdoes 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 anode: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 ciin CI and in image builds. It requires a lockfile, removesnode_modulesfirst, and fails rather than rewriting the lockfile when it disagrees withpackage.json.- Set
ignore-scripts=truein.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-ageto 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. Themin-release-age-excludeescape 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 2FAset overrides both account-level and package-level 2FA for publishing. - Node 22 ships npm 10, so neither
min-release-agenor trusted publishing works on that line without upgrading npm. - Corepack stopped shipping with Node 25, so
"packageManager"is inert unlesscorepackis 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 notnpm run. It skipspreandpostscripts, sets nonpm_package_*variables, and does not pass--env-filevariables to the command.--env-fileis read before the Permission Model initializes, so--permissioncannot restrict which file it reads, and the file can setNODE_OPTIONS.- The real environment overrides an
--env-filevalue. Later--env-fileflags override earlier ones. NODE_OPTIONSrefuses-eand-p. Node exits with-e is not allowed in NODE_OPTIONSrather than ignoring the flag.- A bare script path in
NODE_OPTIONSis dropped silently — the file never runs and nothing warns, measured on 26.2.0. Load code from the variable with--requireor--import. --testdefaults to process isolation, one child per file, and reports asynchronous work that outlives a test as a failure rather than dropping it.--testpicks up.tsfiles under the same name patterns unless--no-strip-typesis 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()andsuite()are the names;it()anddescribe()are aliases. Subtests come from the context —await t.test(...)— andbeforeEach/afterEachfire 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 barenode:assert. Only the context-bound form is counted byt.plan(), so a bareassertleaves 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, nevernode:assert, outside a test. The legacy surface compares with==, soassert.equal(1, '1')passes.assert.CallTrackerwas removed in 25.0.0 (DEP0173) — usemock.fn()andfn.mock.callCount(). - Prefer
t.mockto the importedmock. The context tracker is reset after each test; the module-level one is process-wide and needs a manualmock.reset()ormock.restoreAll(). mock.module()needs--experimental-test-module-mocks. Without the flag it isundefined, so the test dies on aTypeErrorinstead 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 beforemock.timers.enable()runs. Call the global or the namespace property. - Pass
--test-reporterexplicitly. The non-TTY default changed fromtaptospecin 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 theTestsStreamevents 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-snapshotsin CI. It rewrites the baseline, turning every regression into a pass. --test-force-exithides an open handle rather than closing one. Find it withprocess.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.