Nodejs
Agent skills that give AI agents the operational competence of expert practitioners (Salesforce, AWS, GitHub Actions, web). SKILL.md format; Claude Code plugins. Not test-prep — certification is the scaffold and benchmark, not the product.
npx -y skills add toddkasper/expert-skills --skill nodejsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Building and reviewing Node.js applications and services — the event loop and async patterns, streams and buffers, the module system (ESM/CJS), packaging and the toolchain, error handling and diagnostics, and HTTP services with security. Use when writing, reviewing, or debugging Node.js code, CLIs, services, or AWS Lambda handlers. Stays on the Node runtime; excludes React (see react), Next.js (see nextjs), and TypeScript typing (see typescript). Competence skill mapped to the retired OpenJS JSNAD/JSNSD curriculum.
SKILL.md
36.4 KB, as published. Nobody here has run it
Node.js — Skills Reference
Overview
A strong Node.js engineer reasons about the runtime, not just the library. That means understanding how the event loop schedules work, how to move data through streams without blowing memory, how modules resolve at runtime vs at build time, and how to keep services secure against injection and header-based attacks. The retired OpenJS exams (JSNAD and JSNSD) are used here only as a public competence map — they defined what practitioners must know without being about the exam itself.
Load this skill when… writing or reviewing Node.js services, CLIs, or Lambda handlers; debugging event-loop blocking or stream backpressure issues; auditing HTTP security (injection, Helmet, JWT); reviewing module/packaging configuration (ESM, exports map, npm ci). Not this skill: React UI concerns → see
react; Next.js framework (App Router, Server Actions) → seenextjs; TypeScript type-system questions → seetypescript; React Native/mobile runtime → seereact-native.
Deeper context: Study resources, certification history, and learning paths live in references/study-resources.md. Load that file when planning a study path. Sibling skills:
typescript,react,nextjs,react-native.
Verify steps assume nothing about your tooling — use your project's own scripts and the language toolchain (
tsc,node, the test runner, the package manager), in that order of preference.
Credential context (retired JSNAD/JSNSD) and study path: see references/study-resources.md.
Uncertainty & Escalation
- Always re-verify live: Node.js LTS release lines and their API stability tier change on a schedule — APIs stable in Node 22 may be deprecated or removed in Node 24/26.
[volatile — verify live]marks apply to: thenode:testrunner API surface (added Node 18, still gaining features each release);require(esm)support for CJS-loading ESM (available Node 20.17.0+, unflagged in 20.19.0+/22.12.0+, stable in 25.4.0 — behavior varies by version)[volatile — verify live];stream.pipeline()Promise variant availability (node:stream/promises— verify against the project's Node version);worker_threadsAPI surface (stable Node 12+, but options evolve). Checkenginesinpackage.jsonand the Node.js release schedule before relying on a version-specific API. Current LTS lines (2026-06-10): Node 26 is Current; Node 24 (Krypton) is Active LTS (recommended for production); Node 22 (Jod) is Maintenance LTS (security fixes only); Node 20 reached EOL March 24, 2026; Node 18 reached EOL March 27, 2025.[volatile — verify live] - Live wins: the installed Node.js version's actual runtime behavior and the official Node.js docs are authoritative over this file → log discrepancies via Feedback protocol below.
- Escalate to a human: production deploys; dependency major-version bumps (especially
express→fastifyor Node LTS upgrades); data migrations; deleting or force-pushing git history; infrastructure changes (Redis, load balancer config, rate-limit store). - Confidence taxonomy: facts in this file are stable unless tagged
[volatile — verify live]or[opinion — house style].
Specific volatile facts in this skill:
require(esm)in CJS files —[volatile — verify live]— available since Node 20.17.0 (behind--experimental-require-module); unflagged and warning-free in Node 20.19.0+/22.12.0+/23.0.0+; marked fully stable (non-experimental) in Node 25.4.0. Not available in Node 18 or Node 20 < 20.17.0.node:testrunner API (e.g.,--test,--test-reporter) —[volatile — verify live]— features added each LTS cycle.stream.pipeline()fromnode:stream/promises—[volatile — verify live]— verify it exists in the project's Node version (Node 15+).- Fastify vs Express security default behavior (Helmet bundled, AJV schema validation) —
[volatile — verify live]— confirm against installed library version.
1. Async Node.js and the Event Loop
The event loop is the single most misunderstood aspect of Node.js. Every performance problem traces back to either blocking the loop or failing to handle backpressure.
Event loop phases (in order): timers (setTimeout/setInterval) → pending I/O callbacks → idle/prepare → poll (new I/O) → check (setImmediate) → close callbacks. Then repeat.
Microtask queue runs between every phase — and process.nextTick() drains before Promises. Order within a tick: nextTick queue → Promise microtask queue → next event loop phase. Stacking nextTick callbacks recursively starves I/O permanently.
The #1 rule: never block the event loop. Blocking calls — fs.readFileSync, crypto.pbkdf2Sync, zlib.gzipSync, JSON.parse on a 10 MB payload — stop all I/O for every other client until they return. Use async variants. For CPU-bound work (image processing, compression, heavy computation), offload to worker_threads — not child_process.fork() which has IPC overhead, and not setImmediate which just defers to the next tick on the same thread.
async/await pitfalls:
| Pattern | Problem | Fix |
|---|---|---|
await in a for loop | Serializes iterations — N×latency | Promise.all() / Promise.allSettled() for parallel |
| Unhandled rejection | Crashes process in Node 15+ | Always .catch() or try/catch in async |
await on EventEmitter event | Race: event may fire before await | Use events.once() which returns a Promise |
Forgetting await on an async call | Silent fire-and-forget, errors swallowed | Lint with no-floating-promises |
Mixing callbacks with async/await | Error handling logic splits | Promisify at the boundary with util.promisify |
EventEmitter rules:
- Attach error listeners before emitting anything — an unhandled
errorevent throws. - Remove listeners when done (
emitter.off()/removeListener) to prevent memory leaks;once()removes itself automatically. setMaxListeners(n)adjusts the default warning threshold of 10 (useful for intentional high-fan-out patterns, never to silence leaks).
Red flags in review: synchronous *Sync call in a request handler or hot path; process.nextTick recursion; new Promise constructor wrapping an async function (promise constructor anti-pattern); missing error listener on EventEmitter; await inside a forEach (forEach ignores returned Promises).
2. Streams, Buffers & the Filesystem
Use streams for anything that doesn't need to fit in memory at once. Reading a 2 GB file into a Buffer and then writing it out causes a 2 GB memory spike. Streaming it uses a small, bounded buffer.
Stream types: Readable, Writable, Duplex (both), Transform (Duplex that modifies data). All are EventEmitters.
Use stream.pipeline() — not .pipe() for production code. pipeline() wires sources, transforms, and sinks with automatic backpressure and end-to-end error propagation and cleanup. .pipe() does not propagate errors — a source error leaves the destination open.
const { pipeline } = require('node:stream/promises');
await pipeline(readableSource, transformStep, writableDest);
Backpressure — the rule: when writable.write() returns false, the consumer's buffer is full. Stop sending: pause the readable (.pause()) and wait for the drain event before resuming. pipeline() handles this automatically. highWaterMark (default 64 KiB / 65536 bytes for byte streams, 16 objects for object-mode streams) controls the buffer ceiling. [volatile — verify live] — raised from 16 KiB to 64 KiB in nodejs/node#53494.
Buffer: fixed-size raw memory outside V8's heap. Created with Buffer.alloc(n) (zero-fills — safe) or Buffer.allocUnsafe(n) (faster but contains old memory — only use when immediately overwriting). Never create Buffers from user-supplied length without range-checking — a huge n is a DoS vector. Encode/decode with buf.toString('utf8') / Buffer.from(str, 'utf8').
Filesystem patterns:
| Operation | Use | Why |
|---|---|---|
| Stream large files | fs.createReadStream + pipeline | Bounded memory |
| Small config / key files | fs.promises.readFile | Simple, one round-trip |
| Atomic writes | Write to .tmp, then fs.rename | Rename is atomic on POSIX; prevents partial reads |
| Directory walking | fs.promises.opendir / async iteration | Avoids loading full readdir array in memory |
| Watch for changes | fs.watch (not watchFile) | watchFile polls; fs.watch uses OS events |
child_process: Use spawn for streaming I/O (logs, piped output); exec for small output where you want the full stdout/stderr as strings; execFile like exec but safer (no shell interpolation); fork for Node.js child workers with IPC. Never pass unsanitized user input into exec — use spawn with an args array to avoid shell injection.
Red flags: .pipe() without error handling; Buffer.allocUnsafe used for network-supplied sizes; fs.readFileSync in a request path; exec with user-controlled arguments; stream events (data, end, error) wired manually instead of pipeline.
3. Modules, Packaging & the Toolchain
ESM vs CommonJS — the decision:
| CommonJS (CJS) | ESM | |
|---|---|---|
| File extension | .js (default) / .cjs | .mjs / .js with "type":"module" |
| Load | Synchronous, dynamic | Async, static |
| Interop from CJS | require('./foo.cjs') works | require('esm-only-pkg') requires dynamic import() — or require(esm) if Node ≥20.17.0 (unflagged in 20.19.0+/22.12.0+; fully stable in 25.4.0; not available in Node 18 or <20.17.0) [volatile — verify live] |
| Interop from ESM | import cjsPkg from 'cjs-pkg' works (default export = module.exports) | |
| Tree-shaking | ❌ (dynamic require) | ✅ (static analysis) |
__dirname / __filename | Available | Not available — use import.meta.url + fileURLToPath |
Top-level await | ❌ | ✅ |
Rule for new projects: use ESM ("type":"module" in package.json). For libraries that must support CJS consumers, publish a dual package using the exports map:
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
package.json field decisions:
| Field | Rule |
|---|---|
dependencies | Runtime deps — what the app needs when installed by a consumer |
devDependencies | Build/test only — never shipped in a production Docker image |
peerDependencies | Libs that must share the host project's instance (React plugins, etc.) |
engines | Pin the Node.js version range your code is tested against |
exports | Use instead of main for new packages; enables subpath and condition-based resolution |
files | Allowlist what goes into the npm tarball — never publish src/, .env, or test fixtures |
npm ci vs npm install:
npm cireadspackage-lock.jsonexactly, errors if lock and manifest diverge — use in CI and Docker builds.npm installresolves and updates the lock — use during development only.npm audit --omit=devin CI; never ship unfixed critical vulnerabilities.
Semver discipline: ^ (caret) pins major, allows minor/patch — safe for most deps. ~ pins major+minor, allows patch — tighter. Exact pins ("1.2.3") are appropriate for CLIs that users install globally; avoid for libraries (causes dep duplication).
Red flags: devDependencies in dependencies (bloats prod bundle); missing engines field in a published package; npm install in a Dockerfile (non-deterministic); unresolved npm audit critical findings; require() of an ESM-only package in a CJS file without dynamic import.
4. Error Handling & Diagnostics
Async error propagation rules:
- In
async/awaitcode: thrown errors and rejected Promises both surface as rejections —try/catchcatches both. - In callbacks: always check the first argument (
if (err) return cb(err)). Never throw inside a callback — it goes touncaughtException, not the caller. - In streams: errors don't automatically propagate.
pipeline()propagates;.pipe()does not. Always attach.on('error', handler)on every stream if using.pipe(). - Global safety nets:
process.on('unhandledRejection', ...)andprocess.on('uncaughtException', ...)are last-resort loggers, not error handlers. Log and exit — continuing afteruncaughtExceptionleaves the process in an unknown state.
Error design: extend Error for custom types; set error.code (string) for programmatic branching, not error.message (which is human-readable and can change). Operational errors (bad input, network timeout) vs programmer errors (null dereference) — operational errors can be recovered from; programmer errors should crash the process.
Diagnostics:
| Tool | Use |
|---|---|
node --inspect / --inspect-brk | Attach Chrome DevTools or VS Code debugger; --inspect-brk pauses at first line |
node --prof + node --prof-process | V8 CPU profiler; generates tick file → readable report |
node --heap-snapshot / v8.writeHeapSnapshot() | Memory leak analysis via Chrome Memory tab |
NODE_OPTIONS='--max-old-space-size=512' | Cap heap — forces OOM before silent memory spiral |
clinic.js (npm) | Wraps --prof and --trace-gc; produces flame graphs, bubble charts |
node:trace_events | Low-overhead async timeline; diagnose event-loop stalls |
Logging: structured JSON logs (one object per line) beat formatted strings because they're machine-parseable. Include level, msg, timestamp, and a correlationId/requestId on every request log. Never log passwords, tokens, or PII — scrub at the boundary. Use log levels (error, warn, info, debug) and disable debug in production by default.
Red flags: try/catch around an async function without await (the try block exits before the Promise settles — the rejection is unhandled); uncaughtException handler that continues normally; console.error as the only error logging (no structure, no correlation); using error.message for programmatic control flow.
5. HTTP Services & Security
This section maps to the JSNSD scope (Servers & Services 70%, Security 30%).
Framework choice (for exam scope and common use): Express.js is the default; Fastify is faster and schema-first (built-in JSON schema validation + serialization). Hapi is configuration-heavy; for bare Node.js: node:http. Prefer Fastify for new services — its schema validation catches injection vectors at the boundary.
Input validation — the most important security layer:
- Validate type, shape, length, and allowed values before any business logic or database call.
- Never trust
req.body,req.query, orreq.paramswithout schema validation. - Use a schema library (Fastify's built-in AJV, Zod, Joi) — never hand-rolled regexes as the sole guard.
- Reject early and return a 400 with a generic message; do not echo back user input in error messages (information leakage).
Security headers — always use Helmet:
import helmet from 'helmet';
app.use(helmet()); // sets CSP, X-Frame-Options, HSTS, X-Content-Type-Options, etc.
Helmet sets 14+ security headers with safe defaults. Customize CSP per route if needed; do not disable HSTS on production. Configure CORS at the route level for APIs, never globally for admin routes.
Injection prevention decision table:
| Attack vector | Prevention |
|---|---|
| SQL injection | Parameterized queries / prepared statements — never string concatenation |
| NoSQL injection | Validate that input is a string/number before passing to a query — $where / operator injection uses objects |
| Command injection | child_process.spawn with args array — never exec with user input |
| Path traversal | path.resolve + check that result starts with the allowed base directory |
| XSS | Escape output in templates; set CSP; never res.send(userInput) as HTML |
| SSRF | Allowlist outbound URLs; validate host before fetch/http.request |
Auth patterns:
- JWT: verify signature and
expclaim on every request; never decode without verifying; prefer short-lived access tokens + refresh token rotation. - API keys: constant-time comparison (
crypto.timingSafeEqual) — never===(timing attack). - Passwords:
bcrypt/argon2only — neverSHA-256or faster hashes which are trivially brute-forced.
Rate limiting: always apply at the service or API gateway level. express-rate-limit for Express; Fastify has @fastify/rate-limit. Use Redis-backed stores in multi-instance deployments — in-memory rate limiters don't share state across pods.
Red flags: user input concatenated into a query or shell command; Helmet not installed; CORS configured with origin: '*' on an authenticated endpoint; JWT decoded without signature verification; === used for secret or token comparison; npm audit critical vulnerabilities in production deps.
6. Unit Testing
Node.js has a built-in test runner (node:test, stable from Node 18; Assert module for assertions) [volatile — verify live] — no test framework required for simple cases.
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('parses CSV row', () => {
assert.deepEqual(parseRow('a,b,c'), ['a', 'b', 'c']);
});
For richer features (mocking, snapshot, coverage): Jest (most popular ecosystem), Vitest (faster, ESM-native, compatible API), Mocha + Chai (older projects). Match the project's existing choice before introducing a new runner.
What to test (and not): test behavior at module boundaries, not implementation details. Stub I/O (filesystem, network, DB) at the lowest practical layer. Prefer integration tests for stream pipelines — mock streams are error-prone. Mock child_process.spawn carefully; it's easier to extract the shell logic into a testable pure function and call the process-boundary once.
Coverage: 80% line/branch is a useful floor, not a ceiling. 100% coverage with bad assertions is worse than 70% with precise assertions.
7. Process, OS & Node CLI
Covers JSNAD domains Process/OS (6%) and Node.js CLI (4%) — ~10% of the curriculum.
process rules:
process.argvis[node, script, ...userArgs]; usenode:utilparseArgsfor option parsing.process.envvalues are always strings — cast explicitly and provide defaults (Number(process.env.PORT) || 3000); missing vars returnundefinedand coerce silently.process.stdin/stdout/stderrare Readable/Writable streams — pipe-friendly when redirected.process.exit(code)—0= success, non-zero = failure; CLIs must set non-zero so||and CI step-checks catch errors. Never call inside a route handler.process.on('SIGTERM')/'SIGINT'— always handle in servers. Container orchestrators (Kubernetes, ECS) send SIGTERM before force-kill; unhandled SIGTERM exits immediately and drops in-flight requests. Minimal pattern:process.on('SIGTERM', () => server.close(() => process.exit(0))).process.on('exit', fn)— synchronous only; no async work permitted in the handler.process.hrtime.bigint()— nanosecond monotonic timer for benchmarking; neverDate.now()for durations.
node:os: os.cpus().length sets the parallelism ceiling for worker pools (use length - 1). os.tmpdir() gives the platform-correct temp directory — never hardcode /tmp. os.hostname() for log correlation. os.EOL for cross-platform line endings.
CLI flags [volatile — verify live]: --env-file <path> loads a .env without a library (Node 20.6+); --watch restarts on file changes (Node 18.11+); NODE_OPTIONS prepends flags to every invocation; --permission + --allow-fs-read/write/net/child-process sandboxes access (stable Node 22+); --trace-warnings prints stack traces for warnings.
Red flags: hardcoded /tmp (use os.tmpdir()); process.env.VAR without a fallback or explicit cast; SIGTERM unhandled in a containerized service; process.exit() inside a route handler.
Executable Workflows
Workflow 1 — Build a streaming file/transform pipeline with backpressure + error propagation
- Import
pipelinefromnode:stream/promises(not the callback form). → gate:node -e "require('node:stream/promises').pipeline"prints the function without error; if it fails, check your Node version. - Create the source with
fs.createReadStream(inputPath)— nohighWaterMarktuning until a benchmark says otherwise. - Create each transform with
new Transform({ transform(chunk, enc, cb) { … cb(null, processed); } })or use a built-in likezlib.createGzip(). - Create the sink with
fs.createWriteStream(outputPath + '.tmp'). → gate: confirm the.tmpfile is created (no "ENOENT" on the directory). await pipeline(source, ...transforms, sink). The call handles backpressure and propagates errors end-to-end; no manual.on('error')wiring needed.- After
await pipeline(...)resolves,await fs.promises.rename(outputPath + '.tmp', outputPath)— atomic move prevents partial reads. → gate: source and.tmpfiles closed (lsof | grep <pid>shows no dangling fds after rename). - Wrap the whole function in
try/catch; on error,await fs.promises.unlink(outputPath + '.tmp').catch(() => {})for cleanup.
Workflow 2 — Ship a safe HTTP handler (validate → map errors → never log PII)
- Install a schema validator at the project boundary (Fastify uses AJV built-in; for Express add
zodorjoi). Define the expected shape ofreq.body,req.params, andreq.query. - At the top of the handler, parse/validate the input. On failure, return
res.status(400).json({ error: 'Invalid input' })— do not echo back user-supplied values in the message. → gate: send a malformed body; confirm 400 response and that the error message contains no user data. - Map domain/operational errors (DB not found, auth fail) to explicit HTTP status codes in a central error handler — never let a generic
500reveal a stack trace to the client. → gate: trigger each error type in a test; assert the status code and that the response body contains no stack trace. - Strip sensitive fields before logging. Create a
sanitizeHeaders(headers)helper that removesauthorization,cookie, andx-api-key. Log the sanitized headers object, notreq.headersdirectly. → gate: send a request withAuthorization: Bearer TOKEN; grep structured log output forBearer— it must not appear. - Set
Helmeton the app once at startup:app.use(helmet()). → gate:curl -I <endpoint>— response must includeX-Content-Type-OptionsandStrict-Transport-Securityheaders.
Workflow 3 — Package a CLI/lib (deps vs devDeps → lockfile → npm ci in CI)
- Audit
package.json: every package used only in tests, builds, or linting goes indevDependencies. Packages the consumer needs at runtime stay independencies. → gate:npm install --omit=devin a clean directory; the app starts without "Cannot find module" errors. - Add an
enginesfield:{ "engines": { "node": ">=20.0.0" } }. → gate:node --versionin CI matches the range. - Add a
filesarray allowlistingdist/(orlib/),README, andLICENSE. Runnpm pack --dry-runand confirmsrc/,test/, and.env*are absent from the tarball. → gate: tarball size is reasonable; no source maps or test fixtures included. - Commit
package-lock.json. In CI, replacenpm installwithnpm ci— it errors if the lockfile is missing or out of sync, guaranteeing reproducible installs. → gate: CI run usesnpm ci; deliberately introduce a version mismatch inpackage.jsonand confirmnpm cifails with "npm ci can only install packages when your package.json and package-lock.json are in sync." - Run
npm audit --omit=devin the CI pipeline after install; fail the build on critical vulnerabilities. → gate: audit output exits 0 (or a known-OK non-zero when no criticals exist).
Decision Scenarios
Scenario 1 — .pipe() leaves a writable stream open after a readable error
Situation: A service pipes a
ReadStreamto aWriteStreamto copy uploaded files to disk:readStream.pipe(writeStream). In production, network disruptions occasionally cause the read stream to error mid-transfer. Monitoring shows orphaned file handles accumulating on the server.
Competent move: Replace
.pipe()withstream.pipeline()fromnode:stream/promises.pipeline()automatically destroys all streams in the chain and propagates errors end-to-end when any one stream errors..pipe()forwards data but does not propagate errors — the destination stream stays open and the file handle leaks.
Tempting-but-wrong: Adding an
errorlistener to the readable and manually callingwriteStream.destroy()inside it. This closes the gap but is fragile (must be repeated on every new pipeline) and easy to forget.pipeline()is the single-call fix that handles it for every stream in the chain.
Verify: Simulate a read error by destroying the readable mid-transfer. With
.pipe(), confirm the write stream fd remains open (lsof | grep <pid>). After switching topipeline(), confirm the fd is closed immediately on error.
Scenario 2 — CPU-bound work deferred with setImmediate blocks concurrent requests
Situation: A developer breaks up a 500ms JSON-transformation loop by inserting
await new Promise(r => setImmediate(r))every 10,000 iterations so the event loop "can breathe." Under load tests with 20 concurrent requests, response times for all other endpoints still spike dramatically during the transformation.
Competent move: Move the CPU-bound transformation into a
worker_threadsWorker.setImmediatedefers work to the next event-loop iteration on the same thread — it yields briefly but the heavy computation still runs on the main thread and blocks all other I/O while it executes each chunk. Worker threads run on a separate OS thread and never block the event loop.
Tempting-but-wrong: Increasing the chunk size so
setImmediateis called fewer times, believing this reduces the blocking overhead. Fewer interruptions actually mean longer blocking windows per chunk — the total time spent blocking the main thread is essentially unchanged.
Verify: Benchmark concurrent request latency with
autocannonwhile a transformation is running. Compare main-thread deferral vsworker_threads— the worker approach should show near-zero latency impact on concurrent requests.
Scenario 3 — In-memory rate limiter does not share state across pods
Situation: A three-instance Node.js API uses
express-rate-limitwith the default in-memory store. Security testing reveals that an attacker can bypass the 100 req/min limit by distributing requests across the three instances (34 requests per pod per minute — under the per-pod limit).
Competent move: Configure
express-rate-limitwith a Redis-backed store (e.g.rate-limit-redis). In-memory stores track counts per process — each pod has its own counter, so the effective limit isn × per-pod-limitacross a cluster. A shared Redis store means all instances share one counter and the limit is enforced globally.
Tempting-but-wrong: Increasing the per-pod limit to compensate, reasoning that the average distribution will stay under the intended total. This does not enforce the limit reliably — a single attacker who knows the instance count can saturate the service with targeted requests.
Verify: Use a Redis
MONITORcommand during the load test to confirm all instances are incrementing the same key, and verify that a burst from a single client across all pods triggers the limit correctly.
Scenario 4 — Structured log accidentally includes a JWT in the request log
Situation: A Node.js service logs the full
req.headersobject on every request for debugging:logger.info({ headers: req.headers }, 'incoming request'). A security audit finds thatAuthorization: Bearer <jwt>tokens are persisted in the log store.
Competent move: Scrub sensitive headers at the log boundary before calling the logger. Create a helper that strips
authorization,cookie, andx-api-keyfrom the headers object (or logs only an allowlist of safe headers). Log the scrubbed object, never the rawreq.headers.
Tempting-but-wrong: Removing the header-logging line entirely. That's overcorrection — headers like
content-type,user-agent, andx-request-idare useful diagnostics. The correct fix is selective scrubbing, not wholesale deletion.
Verify: Send a request with an
Authorizationheader and grep the log output forBearer. After scrubbing, the JWT must not appear. Also checkcookieandx-api-keyheaders as a regression guard.
Scenario 5 — util.promisify vs manually wrapping a callback — the this binding trap
Situation: A developer wraps a legacy database client method with
new Promise((resolve, reject) => client.query(sql, (err, rows) => err ? reject(err) : resolve(rows))). Code review flags this as a footgun. A teammate instead usesconst query = util.promisify(client.query)and callsquery(sql)— it throws "cannot read property 'pool' of undefined."
Competent move: Bind the method when promisifying:
const query = util.promisify(client.query).bind(client).util.promisifystrips thethiscontext — calling the promisified function as a plain function loses the object reference the method needs internally. Binding restores the correct receiver.
Tempting-but-wrong: Falling back to the
new Promiseconstructor wrapper because "at least it works." The wrapper works, but it's verbose and must be repeated for every method. Binding once onutil.promisifyis the canonical, concise fix.
Verify: Run the promisified query against a live test DB connection. With
.bind(client), the call succeeds. Without it, the error references an internal property of the client object — confirming thethisloss.
Scenario 6 — exec with a user-controlled filename enables command injection
Situation: A file-conversion service accepts a filename from the user and runs:
exec(convert ${req.body.filename} output.pdf). A penetration tester submitsfilename = "input.jpg; rm -rf /tmp/uploads"and the command executes both parts.
Competent move: Switch to
spawn('convert', [req.body.filename, 'output.pdf']).execpasses the full string to the shell — shell metacharacters (; | && $()) are interpreted.spawnwith an explicit argument array bypasses the shell entirely; each element is passed as a literal argument to the process and no shell expansion occurs.
Tempting-but-wrong: Sanitizing
req.body.filenamewith a regex that strips semicolons and pipes. Regex sanitization is incomplete — there are many shell metacharacter forms ($(),`, newlines, null bytes, environment variable expansion) and any missed character is a vulnerability. Structural avoidance (no shell) is always safer than filtering.
Verify: Pass
"input.jpg; echo INJECTED > /tmp/proof"to both theexecandspawnimplementations. Theexecversion creates the file; thespawnversion passes the entire string as a literal filename argument and the injection fails.
Operational Rules Quick Reference
- DO keep all sync
*Synccalls out of request handlers and hot paths — they block the event loop for all concurrent clients. - DON'T use
process.nextTick()recursively — it starves I/O. UsesetImmediate()to defer work past the current phase. - DO use
stream.pipeline()(orstream/promisesvariant) instead of.pipe()—pipeline()propagates errors and cleans up;.pipe()does not. - DON'T ignore
writable.write()returningfalse— respect backpressure or usepipeline()which handles it automatically. - DO use
Buffer.alloc(n)for safe zero-filled allocation;Buffer.allocUnsafe(n)only when immediately overwriting every byte. - DON'T pass user-supplied sizes to
Buffer.allocUnsafewithout range-checking — a giant allocation is a DoS vector. - DO use
child_process.spawnwith an argument array for external commands; neverexecwith user-controlled input. - DO use
npm ciin CI/CD and Docker — nevernpm install(non-deterministic). - DON'T commit
node_modules/; always derive the install frompackage-lock.json. - DO ship
devDependenciesonly in dev/test images — keep production images slim. - DO use ESM (
"type":"module") for new projects andexportsmap for published packages. - DON'T use
__dirnameor__filenamein ESM — usefileURLToPath(import.meta.url)instead. - DO attach an
errorlistener to every EventEmitter before emitting — unhandlederrorevents throw. - DON'T use
process.on('uncaughtException')as an error recovery mechanism — log and exit. - DO install Helmet on every Express/Fastify service — it sets 14+ security headers in one call.
- DON'T use
===for secret or token comparison — usecrypto.timingSafeEqualto prevent timing attacks. - DO validate and sanitize every input at the request boundary before any business logic or DB call.
- DON'T concatenate user input into SQL, shell commands, or file paths — use parameterized queries,
spawnwith args, andpath.resolve+ prefix check. - DO use
bcryptorargon2for passwords — never SHA-256 or MD5. - DO verify JWT signatures and
expon every request; never skip verification. - DON'T log PII, tokens, or passwords — scrub at the log boundary.
Feedback protocol
Using this skill and hit a wall? If you find a claim contradicted by the live system or official docs, a missing rule that cost you a wrong attempt, or a decision this skill gave no criteria for — append an entry in the moment to .skill-feedback/nodejs.md at the project root (create it if absent):
date | skill last-reviewed | claim or gap | what you observed instead | evidence (error text / doc URL / query output) | suggested fix
These are harvested back into the skill via the learning loop. When the live system and this file disagree, trust the live system.
Changelog
- 2026-06-10 — Cycle-4 curation (inbox): (1)
require(esm)volatile-fact corrected — available Node 20.17.0+, unflagged 20.19.0+/22.12.0+, stable 25.4.0; ESM interop table and §Uncertainty bullet updated. (2)highWaterMarkbyte-stream default corrected from 16 KiB to 64 KiB (65536 bytes); object-mode 16 unchanged. (3) Node version matrix refreshed — Node 18 EOL Mar 2025, Node 20 EOL Mar 24 2026; Active LTS = 24 (Krypton), Maintenance LTS = 22 (Jod), Current = 26; framing updated throughout Uncertainty. (4) Added §7 Process, OS & Node CLI covering process.argv/env/signals/exit codes,node:os, and CLI flags (--env-file, --watch, --permission, NODE_OPTIONS) to close the JSNAD ~10% coverage gap.last-reviewedupdated to 2026-06-10. - 2026-06-09 — Conformed to the 12-dimension skill standard: task-vocab description + Scope block, Uncertainty & Escalation guidance with inline
[volatile — verify live]marks, executable workflows, tool-agnostic verify steps, and the feedback protocol above.last-reviewedset to 2026-06-09.
Independent educational content for upskilling AI agents. Node.js and the OpenJS marks are trademarks of the OpenJS Foundation, used here only to identify the subject matter. Not affiliated with, authorized by, or endorsed by the OpenJS Foundation or the Linux Foundation. Content is provided as guidance only — verify against official Node.js documentation and your runtime version.