TS-38: Node.js applications

This technical standard covers building and operating applications on the Node.js runtime: the module system, package and dependency management, configuration, process lifecycle, error handling and logging, and the scaling model Node.js’s single-threaded design requires. It does not cover the JavaScript or TypeScript language itself — see TS-36: ECMAScript (JavaScript/TypeScript) — nor HTTP API design, secrets management, or containerized deployment, which are covered respectively by TS-21: HTTP APIs, TS-52: Security and secrets management, and TS-49: Cloud platform engineering.

Module system

A Node.js application MUST declare its module system explicitly via the "type" field in package.json"module" for ECMAScript modules (ESM) or "commonjs" for CommonJS (CJS) — rather than relying on the runtime’s default. Leaving "type" unset defaults to CommonJS and forces every .mjs or .cjs file in the project to carry an extension that overrides it, which is a worse outcome than stating the choice once.

New applications SHOULD use ESM ("type": "module"). It is the module format defined by the ECMAScript specification, it is what TS-36: ECMAScript (JavaScript/TypeScript) assumes for import/export syntax, and it is the format the wider JavaScript ecosystem has converged on for new packages. CommonJS MAY be kept for an existing application where migration cost outweighs the benefit, but MUST NOT be chosen for a new one without a specific reason (eg. a required dependency that only ships a CJS build).

An application MUST NOT mix require() and import syntax within the same file. Interop between the two module systems is unavoidable at the package boundary — a CJS package consumed from an ESM application, or vice versa — but MUST be confined to the smallest possible surface:

// interop/legacy-logger.js — the only file that imports a CJS-only package
// from an ESM application.
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
const legacyLogger = require("legacy-logger-package")

export default legacyLogger

Important

import() (the dynamic form) MUST be used instead of require() for conditional or lazy loading in ESM code. Dynamic require() calls (a path computed at runtime rather than a static string literal) defeat static analysis tooling — bundlers, tree-shaking, and dependency-graph linters — and SHOULD be treated as a code smell in CommonJS too.

File extensions MUST match the module system in use: .mjs for ESM code inside a project whose package.json still declares "type": "commonjs", and .cjs for CommonJS code inside a project declaring "type": "module". Where package.json already declares the project’s module type, source files SHOULD use the plain .js extension — the explicit .mjs/.cjs forms exist for the mixed case, not as a default style.

Import specifiers MUST use the node: protocol prefix for Node.js built-in modules (import fs from "node:fs", not import fs from "fs"). This disambiguates a built-in from a same-named third-party package at a glance and lets bundlers and loaders resolve the built-in without a filesystem lookup.

Package management

An application MUST commit its lockfile (package-lock.json, yarn.lock, or pnpm-lock.yaml, depending on the chosen package manager) to version control. The lockfile is what makes a build reproducible across machines and over time; a package.json alone only pins version ranges, which resolve to different concrete versions as the registry gains new releases.

Dependency versions in package.json SHOULD use the caret range (^1.2.3) for libraries the application does not need to pin exactly, reserving an exact pin (1.2.3) for a dependency with a history of breaking changes in minor or patch releases, or one the application patches locally. A caret range still resolves deterministically for any given install because the lockfile records the exact version actually installed.

CI MUST install dependencies with the package manager’s frozen-lockfile mode (npm ci, yarn install --immutable, pnpm install --frozen-lockfile), not the mutating install command (npm install). The frozen mode fails the build if package.json and the lockfile have drifted apart, rather than silently re-resolving and writing a new lockfile inside the CI job.

An application MUST distinguish dependencies from devDependencies. Anything required at runtime — including a framework, an ORM, or a logging library — belongs in dependencies; anything used only to build, test, or lint the codebase — a bundler, a test runner, a linter — belongs in devDependencies. This distinction determines what a production install (npm ci --omit=dev) actually ships, and an application that gets it wrong either ships an oversized production image or is missing a runtime dependency that only manifests once devDependencies is dropped.

Note

Where an application is containerized, the effect of a devDependencies/dependencies split is better achieved with a multi-stage Dockerfile — installing the full dependency tree in a build stage and copying only the production node_modules and build output into the final image — than by relying on --omit=dev alone. See TS-49: Cloud platform engineering for containerization and deployment concerns.

Dependencies MUST be scanned for known vulnerabilities as part of CI (npm audit, or an equivalent tool), per TS-52: Security and secrets management. A vulnerability scan MUST NOT be treated as equivalent to reviewing a new dependency before it is added — an audit only catches vulnerabilities disclosed after the dependency was already trusted, not the malicious or low-quality packages that a scan-only policy would otherwise let in unreviewed.

Engine constraints (the Node.js version an application targets) MUST be declared in the engines field of package.json. This is advisory — npm install warns rather than fails, by default — but documents the supported runtime explicitly and lets tooling (nvm, fnm, CI matrix configuration) key off a single source of truth rather than a version number duplicated across a Dockerfile, a CI config file, and a README.

Configuration and environment

An application’s runtime configuration — the values that differ between environments (development, staging, production) — MUST be read from environment variables, not hardcoded or branched on an environment name inside application code. if (env === "production") scattered through a codebase is a sign that an environment-specific value should have been injected instead.

process.env values MUST be validated and parsed once, at startup, into a typed configuration object — not read ad hoc from process.env at the point of use throughout the codebase. Every value read from the environment is a string; an application that treats process.env.PORT as a number without parsing it, or reads a required variable that happens to be unset without noticing, defers a configuration error from startup (where it is loud and immediate) to first use (where it is not):

// config.js
import { z } from "zod"

const schema = z.object({
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
})

// Throws, and exits the process, if a required variable is missing or a
// value fails validation — at startup, not on the first request that
// touches the malformed value.
export const config = schema.parse(process.env)

An application MUST fail fast on invalid or missing required configuration. It MUST exit with a non-zero status code and a clear error message identifying the missing or invalid variable, rather than starting in a partially configured state and failing later, unpredictably, on whichever code path first needs the missing value.

A .env file MAY be used to populate process.env in local development (via a loader such as dotenv), but MUST NOT be relied on in any deployed environment — the deployment platform’s own environment-variable injection (container orchestrator, PaaS, or secrets manager) is the source of truth there. A .env file MUST be listed in .gitignore; a .env.example listing every variable name (with placeholder or non-sensitive default values) SHOULD be committed instead, so a new contributor can see what configuration the application expects without reading the source.

Secret values — credentials, API keys, signing keys — MUST NOT be passed through plain environment variables in production, even though they are commonly read via process.env. See TS-52: Security and secrets management for how a secret is provisioned to a running process; this section covers non-secret configuration only.

Process lifecycle and signals

A long-running Node.js application (an HTTP server, a worker process) MUST handle SIGTERM and perform a graceful shutdown before exiting. The default behavior — the process dies immediately — drops in-flight requests and can leave a database connection or a message-queue acknowledgment in an inconsistent state. Every orchestration platform (Kubernetes, Docker, systemd) sends SIGTERM before escalating to SIGKILL, and gives the process a grace period to act on it:

const server = app.listen(config.PORT)

async function shutdown(signal) {
  console.log(`Received ${signal}, shutting down gracefully.`)

  // Stop accepting new connections; let in-flight requests finish.
  server.close(async () => {
    await database.disconnect()
    await messageQueue.close()
    process.exit(0)
  })

  // Force-exit if shutdown hasn't completed within the grace period the
  // orchestrator allows before it sends SIGKILL.
  setTimeout(() => process.exit(1), 10_000).unref()
}

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

The forced-exit timeout MUST be shorter than the orchestrator’s own grace period (Kubernetes' terminationGracePeriodSeconds, for example), so the application always exits on its own terms rather than being `SIGKILL`ed mid-cleanup.

An application MUST register a handler for uncaughtException and unhandledRejection that logs the error with full context and then terminates the process. Node.js’s own default behavior for an uncaught exception is to print a stack trace and exit, which is close to correct; the handler exists to guarantee structured logging (per Error handling and logging) rather than to recover and keep running. An application MUST NOT catch these events merely to swallow them and continue — a process that has hit an uncaught exception is in an unknown state, and continuing to serve traffic from it risks corrupting further requests:

process.on("uncaughtException", (error) => {
  logger.fatal({ err: error }, "Uncaught exception; exiting.")
  process.exit(1)
})

process.on("unhandledRejection", (reason) => {
  logger.fatal({ err: reason }, "Unhandled promise rejection; exiting.")
  process.exit(1)
})

An application MUST rely on its process supervisor (the orchestrator, or a tool such as systemd or PM2 in a non-containerized deployment) to restart it after a crash, rather than attempting to recover in-process. Node.js has no supported way to reset a corrupted process to a known-good state short of exiting, so a crash-and-restart cycle managed externally is the correct behavior, not a workaround.

Error handling and logging

An application MUST distinguish operational errors — expected failure conditions such as a network timeout, a validation failure, or a database connection drop — from programmer errors — bugs, such as calling a function with the wrong argument type or dereferencing undefined. An operational error is something the application SHOULD anticipate and handle (retry the request, return a 4xx response, log and continue); a programmer error indicates the process is in an unknown state and SHOULD be handled per Process lifecycle and signals — logged and the process restarted — not caught and papered over.

A custom error class hierarchy SHOULD distinguish these categories explicitly, rather than relying on every call site to reason about which category a given Error falls into:

class OperationalError extends Error {
  constructor(message, { cause, statusCode = 500 } = {}) {
    super(message, { cause })
    this.name = this.constructor.name
    this.isOperational = true
    this.statusCode = statusCode
  }
}

class ValidationError extends OperationalError {
  constructor(message, cause) {
    super(message, { cause, statusCode: 400 })
  }
}

class NotFoundError extends OperationalError {
  constructor(message, cause) {
    super(message, { cause, statusCode: 404 })
  }
}

A centralized error-handling middleware (or an equivalent top-level catch in a non-HTTP application) MUST inspect isOperational to decide the response: an operational error’s statusCode and message are safe to return to the caller; anything else MUST be logged in full and returned to the caller as a generic 500 response, without leaking the original message or stack trace. Leaking an unexpected error’s message to a client risks exposing internal implementation details — a file path, a SQL fragment, a dependency’s error format — that were never meant to be public.

Logging MUST be structured (JSON, or a logging library’s structured format, eg. pino or winston) rather than plain console.log strings, so that log lines are machine-parseable by the platform’s log aggregator. Every log line at warn severity or above SHOULD carry a correlation identifier (a request ID, or a trace ID where distributed tracing is in use — see TS-6: Distributed system design) so that related log lines across an asynchronous call chain, or across multiple service instances, can be reconstructed.

console.log MAY still be used for local, throwaway debugging output, but MUST NOT appear in code that reaches a shared branch — it bypasses log-level filtering and structured output, and its removal is easy to forget.

Stateless scaling

Node.js runs JavaScript on a single thread. A CPU-bound operation — parsing a large payload, running a cryptographic hash, rendering a large template — blocks that thread for its entire duration, during which the process cannot service any other request. An application MUST NOT rely on multi-threading within a single Node.js process to scale CPU-bound work; the platform has no shared-memory threading model comparable to the JVM’s or a multi-process web server’s built-in worker pool. worker_threads exists for offloading a specific CPU-bound task, but it MUST NOT be treated as a general concurrency mechanism — it is for isolating one expensive computation off the event loop, not for running the application’s request-handling logic on multiple threads.

The standard scaling pattern is instead horizontal: an application MUST be designed to run as many identical, independent processes behind a load balancer, each handling a share of the traffic on its own single thread. This is the pattern Bluesky’s engineering team uses in production for its TypeScript backend on Node.js, running approximately 192 Node processes behind HAProxy, each at roughly 1% CPU utilization — trading per-process efficiency for a scaling model that is simple to reason about and cheap to operate (Pragmatic Engineer 2024).

For horizontal scaling to work, each process MUST be stateless: it MUST NOT hold request-relevant state in memory (an in-process session store, an in-memory cache the response depends on, a rate-limit counter) that a different process, handling a later request from the same client, would not also have. Any state a request depends on belongs in a shared, external store — a database, a distributed cache such as Redis — reachable by every process alike, so that a load balancer is free to route any request to any process without regard to which instance handled the client’s previous request. See TS-6: Distributed system design for the broader statelessness and idempotency principles this rests on, and TS-49: Cloud platform engineering for the orchestration platform’s role in running and load-balancing the resulting process fleet.

Node.js’s built-in cluster module — which forks multiple worker processes from a single parent and shares a listening socket between them — MAY be used to make use of every CPU core on a single machine, but SHOULD NOT be treated as a substitute for scaling across machines: a process orchestrator (Kubernetes, or an equivalent) already provides multi-process scheduling, health-checked restarts, and load balancing across hosts, and duplicating that logic with cluster inside the application adds an operational layer the platform already owns. Where an application is deployed to such a platform, one Node.js process per container SHOULD be run, and horizontal scaling SHOULD be delegated entirely to the platform’s own replica count.


References