agentsclimarketplace

Nodejs

Skill toddkasper/expert-skills/web/skills/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.

Install
npx -y skills add toddkasper/expert-skills --skill nodejs

Assembled 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) → see nextjs; TypeScript type-system questions → see typescript; React Native/mobile runtime → see react-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: the node:test runner 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_threads API surface (stable Node 12+, but options evolve). Check engines in package.json and 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 expressfastify or 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:test runner API (e.g., --test, --test-reporter) — [volatile — verify live] — features added each LTS cycle.
  • stream.pipeline() from node: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:

PatternProblemFix
await in a for loopSerializes iterations — N×latencyPromise.all() / Promise.allSettled() for parallel
Unhandled rejectionCrashes process in Node 15+Always .catch() or try/catch in async
await on EventEmitter eventRace: event may fire before awaitUse events.once() which returns a Promise
Forgetting await on an async callSilent fire-and-forget, errors swallowedLint with no-floating-promises
Mixing callbacks with async/awaitError handling logic splitsPromisify at the boundary with util.promisify

EventEmitter rules:

  • Attach error listeners before emitting anything — an unhandled error event 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:

OperationUseWhy
Stream large filesfs.createReadStream + pipelineBounded memory
Small config / key filesfs.promises.readFileSimple, one round-trip
Atomic writesWrite to .tmp, then fs.renameRename is atomic on POSIX; prevents partial reads
Directory walkingfs.promises.opendir / async iterationAvoids loading full readdir array in memory
Watch for changesfs.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"
LoadSynchronous, dynamicAsync, static
Interop from CJSrequire('./foo.cjs') worksrequire('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 ESMimport cjsPkg from 'cjs-pkg' works (default export = module.exports)
Tree-shaking❌ (dynamic require)✅ (static analysis)
__dirname / __filenameAvailableNot 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:

FieldRule
dependenciesRuntime deps — what the app needs when installed by a consumer
devDependenciesBuild/test only — never shipped in a production Docker image
peerDependenciesLibs that must share the host project's instance (React plugins, etc.)
enginesPin the Node.js version range your code is tested against
exportsUse instead of main for new packages; enables subpath and condition-based resolution
filesAllowlist what goes into the npm tarball — never publish src/, .env, or test fixtures

npm ci vs npm install:

  • npm ci reads package-lock.json exactly, errors if lock and manifest diverge — use in CI and Docker builds.
  • npm install resolves and updates the lock — use during development only.
  • npm audit --omit=dev in 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/await code: thrown errors and rejected Promises both surface as rejections — try/catch catches both.
  • In callbacks: always check the first argument (if (err) return cb(err)). Never throw inside a callback — it goes to uncaughtException, 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', ...) and process.on('uncaughtException', ...) are last-resort loggers, not error handlers. Log and exit — continuing after uncaughtException leaves 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:

ToolUse
node --inspect / --inspect-brkAttach Chrome DevTools or VS Code debugger; --inspect-brk pauses at first line
node --prof + node --prof-processV8 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_eventsLow-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:

  1. Validate type, shape, length, and allowed values before any business logic or database call.
  2. Never trust req.body, req.query, or req.params without schema validation.
  3. Use a schema library (Fastify's built-in AJV, Zod, Joi) — never hand-rolled regexes as the sole guard.
  4. 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 vectorPrevention
SQL injectionParameterized queries / prepared statements — never string concatenation
NoSQL injectionValidate that input is a string/number before passing to a query — $where / operator injection uses objects
Command injectionchild_process.spawn with args array — never exec with user input
Path traversalpath.resolve + check that result starts with the allowed base directory
XSSEscape output in templates; set CSP; never res.send(userInput) as HTML
SSRFAllowlist outbound URLs; validate host before fetch/http.request

Auth patterns:

  • JWT: verify signature and exp claim 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/argon2 only — never SHA-256 or 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.argv is [node, script, ...userArgs]; use node:util parseArgs for option parsing.
  • process.env values are always strings — cast explicitly and provide defaults (Number(process.env.PORT) || 3000); missing vars return undefined and coerce silently.
  • process.stdin / stdout / stderr are 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; never Date.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

  1. Import pipeline from node: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.
  2. Create the source with fs.createReadStream(inputPath) — no highWaterMark tuning until a benchmark says otherwise.
  3. Create each transform with new Transform({ transform(chunk, enc, cb) { … cb(null, processed); } }) or use a built-in like zlib.createGzip().
  4. Create the sink with fs.createWriteStream(outputPath + '.tmp'). → gate: confirm the .tmp file is created (no "ENOENT" on the directory).
  5. await pipeline(source, ...transforms, sink). The call handles backpressure and propagates errors end-to-end; no manual .on('error') wiring needed.
  6. After await pipeline(...) resolves, await fs.promises.rename(outputPath + '.tmp', outputPath) — atomic move prevents partial reads. → gate: source and .tmp files closed (lsof | grep <pid> shows no dangling fds after rename).
  7. 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)

  1. Install a schema validator at the project boundary (Fastify uses AJV built-in; for Express add zod or joi). Define the expected shape of req.body, req.params, and req.query.
  2. 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.
  3. Map domain/operational errors (DB not found, auth fail) to explicit HTTP status codes in a central error handler — never let a generic 500 reveal 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.
  4. Strip sensitive fields before logging. Create a sanitizeHeaders(headers) helper that removes authorization, cookie, and x-api-key. Log the sanitized headers object, not req.headers directly. → gate: send a request with Authorization: Bearer TOKEN; grep structured log output for Bearer — it must not appear.
  5. Set Helmet on the app once at startup: app.use(helmet()). → gate: curl -I <endpoint> — response must include X-Content-Type-Options and Strict-Transport-Security headers.

Workflow 3 — Package a CLI/lib (deps vs devDeps → lockfile → npm ci in CI)

  1. Audit package.json: every package used only in tests, builds, or linting goes in devDependencies. Packages the consumer needs at runtime stay in dependencies. → gate: npm install --omit=dev in a clean directory; the app starts without "Cannot find module" errors.
  2. Add an engines field: { "engines": { "node": ">=20.0.0" } }. → gate: node --version in CI matches the range.
  3. Add a files array allowlisting dist/ (or lib/), README, and LICENSE. Run npm pack --dry-run and confirm src/, test/, and .env* are absent from the tarball. → gate: tarball size is reasonable; no source maps or test fixtures included.
  4. Commit package-lock.json. In CI, replace npm install with npm ci — it errors if the lockfile is missing or out of sync, guaranteeing reproducible installs. → gate: CI run uses npm ci; deliberately introduce a version mismatch in package.json and confirm npm ci fails with "npm ci can only install packages when your package.json and package-lock.json are in sync."
  5. Run npm audit --omit=dev in 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 ReadStream to a WriteStream to 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() with stream.pipeline() from node: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 error listener to the readable and manually calling writeStream.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 to pipeline(), 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_threads Worker. setImmediate defers 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 setImmediate is 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 autocannon while a transformation is running. Compare main-thread deferral vs worker_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-limit with 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-limit with 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 is n × per-pod-limit across 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 MONITOR command 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.headers object on every request for debugging: logger.info({ headers: req.headers }, 'incoming request'). A security audit finds that Authorization: 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, and x-api-key from the headers object (or logs only an allowlist of safe headers). Log the scrubbed object, never the raw req.headers.

Tempting-but-wrong: Removing the header-logging line entirely. That's overcorrection — headers like content-type, user-agent, and x-request-id are useful diagnostics. The correct fix is selective scrubbing, not wholesale deletion.

Verify: Send a request with an Authorization header and grep the log output for Bearer. After scrubbing, the JWT must not appear. Also check cookie and x-api-key headers 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 uses const query = util.promisify(client.query) and calls query(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.promisify strips the this context — 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 Promise constructor wrapper because "at least it works." The wrapper works, but it's verbose and must be repeated for every method. Binding once on util.promisify is 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 the this loss.


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 submits filename = "input.jpg; rm -rf /tmp/uploads" and the command executes both parts.

Competent move: Switch to spawn('convert', [req.body.filename, 'output.pdf']). exec passes the full string to the shell — shell metacharacters (; | && $()) are interpreted. spawn with 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.filename with 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 the exec and spawn implementations. The exec version creates the file; the spawn version passes the entire string as a literal filename argument and the injection fails.


Operational Rules Quick Reference

  • DO keep all sync *Sync calls 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. Use setImmediate() to defer work past the current phase.
  • DO use stream.pipeline() (or stream/promises variant) instead of .pipe()pipeline() propagates errors and cleans up; .pipe() does not.
  • DON'T ignore writable.write() returning false — respect backpressure or use pipeline() 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.allocUnsafe without range-checking — a giant allocation is a DoS vector.
  • DO use child_process.spawn with an argument array for external commands; never exec with user-controlled input.
  • DO use npm ci in CI/CD and Docker — never npm install (non-deterministic).
  • DON'T commit node_modules/; always derive the install from package-lock.json.
  • DO ship devDependencies only in dev/test images — keep production images slim.
  • DO use ESM ("type":"module") for new projects and exports map for published packages.
  • DON'T use __dirname or __filename in ESM — use fileURLToPath(import.meta.url) instead.
  • DO attach an error listener to every EventEmitter before emitting — unhandled error events 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 — use crypto.timingSafeEqual to 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, spawn with args, and path.resolve + prefix check.
  • DO use bcrypt or argon2 for passwords — never SHA-256 or MD5.
  • DO verify JWT signatures and exp on 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) highWaterMark byte-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-reviewed updated 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-reviewed set 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.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.