NodeJS

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, w…

Iván

@ivangdavila

What This Skill Does

Diagnoses and fixes Node.js runtime issues including async hangs, memory leaks, stream backpressure, module resolution errors, and process lifecycle problems. Covers debugging live processes, optimizing server performance, resolving npm package conflicts, and ensuring clean shutdowns.

Replaces trial-and-error debugging of Node.js production incidents by providing targeted playbooks for common failure modes like event-loop stalls, OOM kills, EMFILE errors, and module-format mismatches.

When to Use It

  • Debug a Node process that hangs or refuses to exit after completing its work
  • Investigate a server returning intermittent 502 errors behind a load balancer
  • Fix an ERR_REQUIRE_ESM or __dirname is not defined error in a mixed ESM/CJS project
  • Resolve npm ci failures caused by a lockfile out of sync with package.json
  • Profile a Node service that pins one CPU core at 100% and slows all requests
  • Diagnose a container that gets OOMKilled overnight despite stable heap usage

Install

$ openclaw skills install @ivangdavila/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

SituationPlay
One core pinned, every request slows at onceEvent loop blocked. Record node --cpu-prof, read widest self-time frames, not deepest stacks → performance.md
Process finishes its work but never exitsAn open handle: server, socket, setInterval, worker, or pool. process.getActiveResourcesInfo() names it → debug.md
RSS climbs while heapUsed stays flatBuffers/native memory, not a JS leak — heap snapshots will show nothing → performance.md
Intermittent 502/504 behind ALB or nginxkeepAliveTimeout below the proxy's idle timeout (rule 5) → http.md
ERR_REQUIRE_ESM, ERR_MODULE_NOT_FOUND, __dirname is not definedModule-format mismatch or a missing file extension → modules.md
EADDRINUSE on restartOld process still holding the port, or two workers binding it; in tests, bind port 0 → debug.md
EMFILE / ENFILEUnbounded concurrency or leaked descriptors (rule 4) → filesystem.md
Exit 134 with FATAL ERROR: ... heap out of memoryV8's heap ceiling, not the container's limit (rule 8) → performance.md
Memory grows during a copy, upload, or exportBackpressure ignored: write() returned false and nobody waited → streams.md
npm install works, npm ci failsLockfile out of sync with package.json (rule 6) → packages.md
Native module fails to build or load after an upgradeABI mismatch, missing toolchain, or musl vs glibc → runtime.md
Child process hangs, or its output is truncatedmaxBuffer exceeded, or nobody drains the stdio pipe → concurrency.md
A .ts file won't run, or imports resolve at build but not at runtimeType-stripping limits or moduleResolution mismatch → typescript.md
CLI crashes with EPIPE when piped into headstdout closed early; handle the error instead of letting it throw → cli.md
Suite green locally, red in CIPort collisions, fake timers, shared module state, file ordering → testing.md
Untrusted input reaching exec, a path join, or a regexInjection, traversal, ReDoS — the exact checks → security.md
SIGTERM drops in-flight requests on deployNo drain sequence, or node is PID 1 with no signal handler → production.md
Anything elseReproduce 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

  1. 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).
  2. 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.
  3. "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.
  4. 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.
  5. 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).
  6. 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).
  7. 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.
  8. 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.

CodeMeaningFirst move
1Uncaught fatal exceptionRead the stack; an empty one means a non-Error was thrown (→ Traps)
3 / 5Internal JS parse failure / fatal V8 errorAlmost always a bad --flag or a corrupt install, not your code
7Exception inside an exception handlerYour uncaughtException hook itself threw
9Invalid argument to the node binaryFlag typo, or a flag this major doesn't support (→ runtime.md)
130SIGINT (128+2)Ctrl-C; clean unless a handler swallowed it
134SIGABRT (128+6)V8 fatal — usually heap OOM (rule 8) or a failed native assertion
137SIGKILL (128+9)Kernel or orchestrator OOM kill, or a stop timeout expiring (rule 8)
143SIGTERM (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.

FeatureMinimum
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 stable20
--env-file=.env built in20.6
import.meta.dirname / import.meta.filename20.11
node --run <script>, --watch stable22
require() of a synchronous ESM graph22.12
Running .ts by type stripping, unflagged24 (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.

VariableTypeDefaultEffect
package_managernpm | pnpm | yarn | bunnpmEvery install, lockfile, workspace, and CI command; which npm ci equivalent rule 6 names
module_systemesm | cjsesmImport syntax and extensions in all examples, type field guidance, which half of modules.md leads
node_targetnumber (major, >=20)24Which APIs the guidance may use without a fallback — read against the Version Gates table
test_runnernode | jest | vitestnodeTest, mock, and fake-timer idioms; the coverage and watch flags quoted in testing.md
deploy_targetcontainer | vm | serverless | paascontainerSignal and PID-1 handling, cluster vs replicas (rule 7), heap sizing (rule 8), shutdown window
ts_runnernone | strip | tsx | tscnoneWhether .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

TrapWhy it failsDo instead
Unhandled promise rejectionTerminates the process by default (node >=15).catch() or try/catch on every await chain
.pipe() without error wiringErrors don't cross pipes; the source leaks, then crashesstream.pipeline()
Forgotten await inside try/catchThe rejection skips the catch and surfaces later, or neverLint with no-floating-promises
exports = x in CJSRebinds a local alias; module.exports is unchangedmodule.exports = x
__dirname in ESMDoesn't exist thereimport.meta.dirname, else fileURLToPath(import.meta.url)
Mutating a require()d objectModule cache: every consumer sees the mutationExport factories, or freeze exports
process.env.PORT used as a numberEnv values are strings ("3000") or undefinedParse and validate the whole env once at startup
Listeners added per requestAccumulate to MaxListenersExceededWarning — a leak signal, not a limitonce(), or remove on completion
Raising the max-listeners limit to silence that warningHides the leak that produced it; memory keeps climbingFind who adds a listener per event and remove it
Throwing non-Error valuesNo stack; instanceof Error checks fail; exit 1 with an empty tracethrow new Error(msg, { cause })
node:vm as a sandboxNot a security boundary — its own docs say soIsolate untrusted code at process or container level
npm audit fix --force on a red reportApplies major bumps that break the app to silence findings in code that may never runTriage by reachability, pin with overrides (→ packages.md)
node index.js as PID 1 in a containerPID 1 gets no default signal handlers: SIGTERM is ignored and every stop burns the full grace windowRegister 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.

Top skills in this category