# Nodejs

> Node.js backend runtime and process-lifecycle rules that Claude reliably gets wrong. Use when writing a Node server or long-running script, an Express app, worker threads, or process signal handling, and when choosing packages. Covers correct graceful shutdown, crash-on-fault instead of swallowing errors, not blocking the event loop, loading instrumentation first, securing the session cookie, and replacing deprecated packages with built-ins. Defers MongoDB to mongodb-rules, schema/validation to schema-source-of-truth, and image/deploy to docker and docker-swarm.

- Skill: `thedecipherist/nodejs` (Agent Skill)
- Install (CLI): `npx skillmds@latest add thedecipherist/nodejs`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thedecipherist/nodejs/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: TheDecipherist (https://skillmd.com/u/thedecipherist)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thedecipherist/nodejs

---


# Node.js: Process Lifecycle and Runtime Rules

Claude writes Node services that work in a demo and fall over in production, almost always around the process lifecycle. These are the parts to get right.

## Graceful shutdown, done correctly

A server must shut down cleanly on `SIGTERM` (what `docker stop`, Swarm, and Kubernetes send) and `SIGINT` (Ctrl-C). Claude usually omits this entirely, so the orchestrator waits the grace period and then SIGKILLs, dropping in-flight requests. The correct sequence is: stop accepting new connections, drain in-flight ones, close dependencies, exit 0, with a hard timeout so a stuck connection can't block shutdown forever.

```javascript
const server = app.listen(port);
let shuttingDown = false;

async function shutdown(signal) {
  if (shuttingDown) return;                 // ignore repeat signals
  shuttingDown = true;
  logger.info("shutting down", { signal });

  const force = setTimeout(() => {           // drain hung? force it
    logger.error("shutdown timed out, forcing exit");
    process.exit(1);
  }, 10_000);
  force.unref();

  try {
    await new Promise((r) => server.close(r)); // stop new conns, let in-flight finish
    await db.close();                          // then close DB, redis, change streams
    clearTimeout(force);
    process.exit(0);
  } catch (err) {
    logger.error("error during shutdown", { err });
    process.exit(1);
  }
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT",  () => shutdown("SIGINT"));
```

Two things that look fine but are bugs: do not put async cleanup in a `process.on("exit", ...)` handler, the event loop is already stopped so nothing async runs, `exit` is for synchronous work only. And do not trap `SIGUSR1`, Node uses it for the debugger. This only works if the process actually receives the signal, which means an exec-form `ENTRYPOINT` so Node is PID 1 (see the docker skill) and `init: true` so signals are forwarded (see docker-swarm).

## Let it crash, never swallow a fault

On `uncaughtException` or `unhandledRejection` the process is in an unknown, possibly corrupt state. Log it and exit non-zero, let the orchestrator restart a clean process. Do not catch-and-continue. Modern Node already terminates on an unhandled rejection by default, so code that relies on swallowing one is both wrong and fragile.

```javascript
process.on("uncaughtException", (err) => {
  logger.error("uncaught exception", { err });
  process.exit(1);            // exit non-zero so restart_policy: on-failure restarts
});
process.on("unhandledRejection", (reason) => {
  logger.error("unhandled rejection", { reason });
  process.exit(1);
});
```

The non-zero exit is what makes Swarm/K8s self-healing fire, an `exit(0)` on a crash reads as success and the dead service is never restarted (see docker-swarm). You can route these through `shutdown()` to drain first, but never let the process keep serving after one.

## Don't block the event loop

Node runs your JavaScript on a single thread. A CPU-bound stretch, parsing a huge payload, hashing, image work, a tight loop over a large array, freezes every concurrent request until it finishes. I/O is already async and is not the problem. For real CPU work, offload to `worker_threads`, not `child_process` (for in-process JS) and not "just make it async" (await doesn't yield during a synchronous loop).

```javascript
const { Worker } = require("node:worker_threads");
new Worker("./workers/process.js", {
  workerData,
  resourceLimits: { maxOldGenerationSizeMb: 512 },  // cap so one worker can't OOM the host
});
```

## Load instrumentation before anything else

APM and tracing libraries (`dd-trace`, the OpenTelemetry SDK) work by monkey-patching `http`, `express`, and your DB driver. They can only patch modules loaded after them, so the init call must be the very first thing in the entry file, before any `require("express")`. Required late, it silently instruments nothing.

```javascript
// server.js, line 1
require("dd-trace").init({ /* ... */ });
const express = require("express");   // now traced
```

## Lock down the session cookie

When Claude sets up sessions it usually sets `httpOnly` and stops. Set all three: `httpOnly` (no JS access), `secure` in production (HTTPS only), and `sameSite` (CSRF defense), which is the one that gets missed.

```javascript
cookie: { httpOnly: true, secure: isProd, sameSite: "lax", maxAge: 86_400_000 }
```

Related CORS gotcha: `credentials: true` cannot be combined with `origin: "*"`, the browser rejects it. Echo a specific allowed origin instead.

## Reach for built-ins, replace deprecated packages

Claude's training pulls in libraries that are now deprecated or unnecessary. Prefer the platform:

- `crypto.randomUUID()` over the `uuid` package for a v4 id, and `uuid` over `node-uuid`
- native `fetch` (Node 18+) or `axios` over `request` (unmaintained since 2020)
- `node:test` + `node:assert` for simple suites, `structuredClone()` over a deep-clone dep
- the Intl APIs or `date-fns`/Temporal over `moment` (in maintenance mode)
- `@aws-sdk/client-*` v3 (modular) over the monolithic `aws-sdk` v2
- `sass` (dart-sass) over the deprecated `node-sass`

Use the `node:` prefix on built-in imports (`require("node:fs")`) so there's no ambiguity with a same-named package.

## Two smaller ones

- **Logging:** a structured logger (pino or winston) emitting JSON to stdout in production, never `console.log` in a hot path. stdout because the container's logging driver collects it (see docker).
- **PM2:** cluster mode is for using all cores on a VM or bare-metal host. Inside a Swarm or K8s container, run one Node process and scale with replicas plus `init: true`, don't stack two process managers that both try to own restarts.

---

This skill is built to grow. Add a rule when a real Node production failure has a stable, defensible fix.

