| name | nodejs |
| slug | nodejs |
| version | 1.0.4 |
| description | Builds, debugs, and hardens Node.js servers, CLIs, and npm packages: async, modules, streams, memory, and process lifecycle. Use when writing or reviewing code that runs on Node, when a process hangs or refuses to exit, leaks memory, gets OOM-killed, pins one CPU core at 100%, or dies on an unhandled rejection; when the error reads EADDRINUSE, EMFILE, ECONNRESET, ERR_MODULE_NOT_FOUND, ERR_REQUIRE_ESM, or "__dirname is not defined"; when import and require interop breaks, streams buffer everything in RAM, a server returns intermittent 502s behind a load balancer, or SIGTERM drops in-flight requests; when npm install, lockfiles, peer dependencies, workspaces, native module builds, or publishing misbehave; when a suite passes locally and fails in CI; or when containerizing, profiling, and shutting down a service cleanly. Not for browser-only JavaScript, TypeScript type-system design, or the Bun and Deno runtimes. |
| homepage | https://clawic.com/skills/nodejs |
| changelog | Full coverage pass: deeper guides, situation-named files, and per-user configuration |
| metadata | {"clawdbot":{"emoji":"💚","requires":{"bins":["node"]},"os":["linux","darwin","win32"],"displayName":"NodeJS","configPaths":["~/Clawic/data/nodejs/"]}} |
User preferences and memory live in ~/Clawic/data/nodejs/ (see setup.md on first use, memory-template.md for the file format). If you have data at an old location (~/nodejs/ or ~/clawic/nodejs/), move it to ~/Clawic/data/nodejs/.
When To Use
- Writing or reviewing Node services, CLIs, libraries, workers, or release scripts
- Debugging a live process: hangs, crash loops, growing RSS, slow endpoints, import failures, unclean shutdown
- Deciding the runtime shape of a package: ESM vs CJS, exports map, lockfile policy, native dependencies, publish surface
- Production incidents: intermittent 502s behind a proxy, EMFILE, event loop stalls, containers OOMKilled overnight
- Running TypeScript, tests, or child processes on Node — the runtime side, not the type system
- Not for browser-only JavaScript, TypeScript type design, or the Bun/Deno runtimes (each has its own skill)
Quick Reference
| Situation | Play |
|---|
| One core pinned, every request slows at once | Event loop blocked. Record node --cpu-prof, read widest self-time frames, not deepest stacks → performance.md |
| Process finishes its work but never exits | An open handle: server, socket, setInterval, worker, or pool. process.getActiveResourcesInfo() names it → debug.md |
RSS climbs while heapUsed stays flat | Buffers/native memory, not a JS leak — heap snapshots will show nothing → performance.md |
| Intermittent 502/504 behind ALB or nginx | keepAliveTimeout below the proxy's idle timeout (rule 5) → http.md |
ERR_REQUIRE_ESM, ERR_MODULE_NOT_FOUND, __dirname is not defined | Module-format mismatch or a missing file extension → modules.md |
EADDRINUSE on restart | Old process still holding the port, or two workers binding it; in tests, bind port 0 → debug.md |
EMFILE / ENFILE | Unbounded concurrency or leaked descriptors (rule 4) → filesystem.md |
Exit 134 with FATAL ERROR: ... heap out of memory | V8's heap ceiling, not the container's limit (rule 8) → performance.md |
| Memory grows during a copy, upload, or export | Backpressure ignored: write() returned false and nobody waited → streams.md |
npm install works, npm ci fails | Lockfile out of sync with package.json (rule 6) → packages.md |
| Native module fails to build or load after an upgrade | ABI mismatch, missing toolchain, or musl vs glibc → runtime.md |
| Child process hangs, or its output is truncated | maxBuffer exceeded, or nobody drains the stdio pipe → concurrency.md |
A .ts file won't run, or imports resolve at build but not at runtime | Type-stripping limits or moduleResolution mismatch → typescript.md |
CLI crashes with EPIPE when piped into head | stdout closed early; handle the error instead of letting it throw → cli.md |
| Suite green locally, red in CI | Port collisions, fake timers, shared module state, file ordering → testing.md |
Untrusted input reaching exec, a path join, or a regex | Injection, traversal, ReDoS — the exact checks → security.md |
| SIGTERM drops in-flight requests on deploy | No drain sequence, or node is PID 1 with no signal handler → production.md |
| Anything else | Reproduce with the smallest script that still fails, then re-add flags, env vars, and imports one at a time |
Depth on demand: debug.md symptom→cause chains · commands.md diagnostic toolkit · async.md event loop and promises · modules.md ESM/CJS · errors.md failure handling and shutdown · streams.md backpressure · http.md servers, clients, timeouts · performance.md profiling and leaks · concurrency.md workers, cluster, child processes · filesystem.md files, paths, descriptors · security.md hardening · testing.md suites and mocks · packages.md npm, lockfiles, publishing · typescript.md running TS on Node · runtime.md versions, flags, native modules · production.md deploy, config, logging, observability · cli.md command-line tools.
Core Rules
- Budget sync work at ~10 ms per slice. Max throughput per process ≈ 1000 ms ÷ block_ms: a 50 ms sync
JSON.parse in a request handler caps that process near 20 req/s, and every concurrent request inherits the stall. Repeatedly over budget → worker_threads; one-off → partition with setImmediate (→ async.md).
- Handle operational errors, crash on programmer errors (Joyent doctrine). ECONNRESET, ENOENT, bad user input → handle at the call site. TypeError, undefined property → let it crash; the supervisor restarts a clean process. Catch-all recovery keeps corrupted state serving traffic.
- "Async" fs, dns.lookup, crypto, and zlib share 4 libuv threads (default
UV_THREADPOOL_SIZE; max 1024). The 5th concurrent fs.promises.readFile queues behind the first 4. Slow "async" crypto/fs under load → raise the pool to roughly the number of concurrent slow calls, or move the work off the process.
- Cap concurrency explicitly.
Promise.all(items.map(fetch)) over 10k items opens 10k sockets — EMFILE at the common 1024 fd soft limit. Batch or use a limiter; 8-32 concurrent per upstream host is a sane starting cap, raised on evidence: throughput climbing with flat latency = raise it; latency climbing = you found the ceiling.
keepAliveTimeout must exceed the proxy's idle timeout. Node's default is 5 s; ALB's default idle is 60 s, so Node closes sockets the balancer just reused → intermittent 502s. Formula: keepAliveTimeout = proxy_idle + 5 s and headersTimeout = keepAliveTimeout + 1 s (ALB 60 s → 65 s / 66 s; nginx upstream keepalive_timeout 75s → 80 s / 81 s).
- Exact versions for apps, ranges for libraries;
npm ci in anything automated. npm install rewrites the lockfile whenever package.json allows it — in CI that means testing an install nobody reviewed (→ packages.md).
- One process per container. Let the orchestrator scale replicas;
cluster only on bare metal or VMs you own entirely — two schedulers fighting over the same cores obscures both.
- Set the heap ceiling below the memory limit. RSS = heap + buffers + stacks + native, so a default-sized heap under a container limit invites an OOMKill instead of a catchable heap error. Formula:
--max-old-space-size = floor(0.75 × container_limit_MB) — 512 MB limit → 384; 2 GB → 1536. Exit 134 with FATAL ERROR: ... heap out of memory means you hit V8's ceiling; exit 137 means the kernel hit yours first.
Exit and Error Codes
Exit codes above 128 mean killed by signal code − 128; Node's own fatal codes sit below 10.
| Code | Meaning | First move |
|---|
| 1 | Uncaught fatal exception | Read the stack; an empty one means a non-Error was thrown (→ Traps) |
| 3 / 5 | Internal JS parse failure / fatal V8 error | Almost always a bad --flag or a corrupt install, not your code |
| 7 | Exception inside an exception handler | Your uncaughtException hook itself threw |
| 9 | Invalid argument to the node binary | Flag typo, or a flag this major doesn't support (→ runtime.md) |
| 130 | SIGINT (128+2) | Ctrl-C; clean unless a handler swallowed it |
| 134 | SIGABRT (128+6) | V8 fatal — usually heap OOM (rule 8) or a failed native assertion |
| 137 | SIGKILL (128+9) | Kernel or orchestrator OOM kill, or a stop timeout expiring (rule 8) |
| 143 | SIGTERM (128+15) | Normal stop; if requests were dropped, the drain sequence is missing (→ production.md) |
Error codes worth recognizing on sight: EADDRINUSE (port held), EMFILE/ENFILE (descriptor limit, rule 4), ECONNRESET (peer closed mid-flight — operational, rule 2), EPIPE (downstream closed a pipe), ERR_REQUIRE_ESM and ERR_MODULE_NOT_FOUND (format or extension), ERR_UNHANDLED_REJECTION (rule 2), ERR_STRING_TOO_LONG (V8 caps string length in the hundreds of MB — stream instead of JSON.stringify), ERR_CHILD_PROCESS_STDIO_MAXBUFFER (child output over the buffer cap).
Timeout Ladder
Every timeout in the path must be ordered, or one layer kills a connection another layer still believes in. Each line reads strictly larger left to right:
client abort < upstream call timeout < inbound requestTimeout
proxy idle < keepAliveTimeout < headersTimeout
force-exit backstop < orchestrator kill window
- Node HTTP server defaults (node >=18):
keepAliveTimeout 5 s, headersTimeout 60 s, requestTimeout 300 s, server.timeout 0 (disabled). Only the first is short enough to bite by accident (rule 5).
- Outbound calls need their own deadline — an upstream that never answers holds a socket, a descriptor, and a request slot.
AbortSignal.timeout(ms), with retries × per-try timeout still under requestTimeout.
- Shutdown backstop = orchestrator grace window − 5 s (Kubernetes
terminationGracePeriodSeconds defaults to 30 s → force-exit at 25 s), so you exit on your own terms with logs flushed instead of by SIGKILL.
Version Gates
The canonical minimums this skill's guidance assumes. Check node -v before recommending anything above the user's line, and name the fallback when the target is older.
| Feature | Minimum |
|---|
| Unhandled rejection terminates the process (default) | 15 |
new Error(msg, { cause }) | 16.9 |
AbortSignal.timeout(), process.getActiveResourcesInfo() | 17.3 |
Global fetch/undici (stable from 21) | 18 |
node:test runner stable | 20 |
--env-file=.env built in | 20.6 |
import.meta.dirname / import.meta.filename | 20.11 |
node --run <script>, --watch stable | 22 |
require() of a synchronous ESM graph | 22.12 |
Running .ts by type stripping, unflagged | 24 (backported to 22.18) |
Release cadence, so "supported" stays checkable instead of remembered: one new major every April and October; even majors enter LTS that October and get ~3 years total (6 months Current, 12 Active LTS, 18 Maintenance); odd majors die after ~6 months and never belong in production. Which majors are live today: runtime.md (dated).
Output Gates
Before shipping Node code, verify:
- Any
*Sync call, unbounded JSON.parse, or whole-array loop reachable from a request handler? (rule 1)
- Every
Promise.all bounded by something other than user input? (rule 4)
- Every stream wired through
pipeline() or carrying its own error handler — an unhandled stream error is a process crash?
- Every outbound call carrying a timeout or AbortSignal, and every server timeout ordered per the ladder?
- All
process.env reads parsed and validated once at startup — they are strings or undefined, never numbers or booleans?
- Shutdown path present: SIGTERM → fail readiness → stop accepting → drain → close pools → exit, in that order (→
errors.md)?
- Heap ceiling set against the container limit (rule 8), and one process per container (rule 7)?
Configuration
User-dependent variables. Defaults apply until the user states a preference; store them in ~/Clawic/data/nodejs/config.yaml.
| Variable | Type | Default | Effect |
|---|
| package_manager | npm | pnpm | yarn | bun | npm | Every install, lockfile, workspace, and CI command; which npm ci equivalent rule 6 names |
| module_system | esm | cjs | esm | Import syntax and extensions in all examples, type field guidance, which half of modules.md leads |
| node_target | number (major, >=20) | 24 | Which APIs the guidance may use without a fallback — read against the Version Gates table |
| test_runner | node | jest | vitest | node | Test, mock, and fake-timer idioms; the coverage and watch flags quoted in testing.md |
| deploy_target | container | vm | serverless | paas | container | Signal and PID-1 handling, cluster vs replicas (rule 7), heap sizing (rule 8), shutdown window |
| ts_runner | none | strip | tsx | tsc | none | Whether .ts guidance assumes type stripping, a loader, or a build step (typescript.md) |
Preference areas — customizable dimensions; a stated preference gets recorded in config.yaml and applied:
- Tooling — script runner (
node --run vs package-manager scripts), linter/formatter stack, profiler of choice — affects the shape of every command example
- Conventions — extensions (
.mjs/.cjs vs a type field), error-class taxonomy, script names, folder layout, log field names
- Platform — OS (Windows path and
spawn caveats), architecture (arm64 prebuilds), base image (musl vs glibc), registry mirror
- Thresholds — concurrency cap per upstream (rule 4), heap fraction (rule 8), timeout values, stream
highWaterMark
- Risk posture — crash-vs-catch aggressiveness (rule 2),
--ignore-scripts on install, automatic major bumps, permission model
- Output format — log shape (JSON vs pretty), verbosity, how much error detail reaches clients
- Work order — test-first vs code-first, profile-before-optimize, review gates before publish
- Integrations — registry, observability vendor, CI provider, process supervisor
- Constraints — banned dependencies, license policy, no-native-modules, offline or air-gapped installs
- Cadence — dependency update rhythm, Node upgrade rhythm against the April/October cadence
Traps
| Trap | Why it fails | Do instead |
|---|
| Unhandled promise rejection | Terminates the process by default (node >=15) | .catch() or try/catch on every await chain |
.pipe() without error wiring | Errors don't cross pipes; the source leaks, then crashes | stream.pipeline() |
Forgotten await inside try/catch | The rejection skips the catch and surfaces later, or never | Lint with no-floating-promises |
exports = x in CJS | Rebinds a local alias; module.exports is unchanged | module.exports = x |
__dirname in ESM | Doesn't exist there | import.meta.dirname, else fileURLToPath(import.meta.url) |
Mutating a require()d object | Module cache: every consumer sees the mutation | Export factories, or freeze exports |
process.env.PORT used as a number | Env values are strings ("3000") or undefined | Parse and validate the whole env once at startup |
| Listeners added per request | Accumulate to MaxListenersExceededWarning — a leak signal, not a limit | once(), or remove on completion |
| Raising the max-listeners limit to silence that warning | Hides the leak that produced it; memory keeps climbing | Find who adds a listener per event and remove it |
| Throwing non-Error values | No stack; instanceof Error checks fail; exit 1 with an empty trace | throw new Error(msg, { cause }) |
node:vm as a sandbox | Not a security boundary — its own docs say so | Isolate untrusted code at process or container level |
npm audit fix --force on a red report | Applies major bumps that break the app to silence findings in code that may never run | Triage by reachability, pin with overrides (→ packages.md) |
node index.js as PID 1 in a container | PID 1 gets no default signal handlers: SIGTERM is ignored and every stop burns the full grace window | Register a SIGTERM handler, or run an init (→ production.md) |
Where Experts Disagree
- Crash-and-restart vs catch-and-continue. Crash when a supervisor restarts you and in-process state may be corrupt; continue only for operational errors scoped to a single request. The boundary question: can you name exactly what stayed consistent?
- Dual CJS+ESM packages vs ESM-only. Dual carries the two-instance hazard (
instanceof fails across copies, module state duplicates); ESM-only is viable once consumers are ESM or run node >=22.12, where require() of a sync ESM graph works.
node:test vs Jest vs Vitest. node:test for new libraries — no transform layer, native ESM, no hoisting magic; Jest for existing suites with heavy matcher and snapshot use; Vitest where the project already runs a Vite pipeline. Migrating a green suite for aesthetics is a net loss.
- TypeScript at runtime vs a build step. Type stripping deletes the toolchain for apps deployed from source; a
tsc build stays correct for published libraries that must ship .d.ts and support older consumers.
Related Skills
More Clawic skills, get them at https://clawic.com/skills/nodejs (install if the user confirms):
javascript — language-level semantics: coercion, closures, dates, regex
typescript — type-system design, tsconfig strictness, declaration files
docker — containerizing the service, image layers, resource limits
api — HTTP API design, versioning, and contracts above the runtime
Feedback
Part of Clawic, the verified skill library. Get this skill: https://clawic.com/skills/nodejs.