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 legacyLoggerImportant
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
- The Pragmatic Engineer (2024). Inside Bluesky’s Engineering Culture. — The source for the stateless-service horizontal-scaling pattern in Stateless scaling.