Node.js Best Practices in 2026: 15 Rules for Production
Modern Node.js best practices — LTS versions, async error handling, worker threads, input validation, structured logging, security hardening, graceful shutdown and streams — each with why and a code example.
Node.js runs an enormous share of the modern back end, and getting it production-ready is about more than writing routes: it is error handling, security, observability and not blocking the single thread everything runs on. Here are 15 Node.js best practices for 2026, each with the reasoning and a code example. As of mid-2026, Node 24 "Krypton" is the Active LTS — target it for new projects.
1. Run a supported LTS version
Only supported release lines get security patches and performance fixes. New projects should target Node 24 (Active LTS); keep existing production on 22 until dependencies are verified. Node 20 is end-of-life — upgrade off it.
// package.json
"engines": { "node": ">=24" }
// .nvmrc
// 24
2. Use async/await with proper error handling
async/await reads sequentially and integrates with try/catch, replacing nested callbacks and manual promise chaining. Note that await in a for loop serializes work — for independent tasks use await Promise.all([...]) (or Promise.allSettled when partial failure is acceptable).
async function getUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
logger.error({ err, id }, "getUser failed");
throw err; // don't swallow — rethrow or handle
}
}
3. Never block the event loop — offload CPU work to worker_threads
Node runs JavaScript on one thread, so a heavy synchronous loop (hashing, parsing, image work) freezes every request. Move CPU-bound work to worker_threads. Don’t spawn a worker per request — use a fixed pool sized to os.availableParallelism(), and avoid sync APIs like fs.readFileSync in request paths.
import { Worker } from "node:worker_threads";
function runHeavy(data) {
return new Promise((resolve, reject) => {
const w = new Worker("./heavy-worker.js", { workerData: data });
w.once("message", resolve);
w.once("error", reject);
});
}
4. Handle unhandled rejections and process-level errors
An uncaught exception or unhandled rejection leaves the process in an unknown state; the safe policy is log-and-exit, then let your supervisor restart cleanly. Do not catch uncaughtException and continue — the runtime may be corrupt.
process.on("unhandledRejection", (reason) => {
logger.error({ reason }, "unhandledRejection");
throw reason; // promote to uncaughtException
});
process.on("uncaughtException", (err) => {
logger.fatal({ err }, "uncaughtException — exiting");
process.exit(1); // let the process manager restart
});
5. Keep config and secrets in environment variables
Secrets in source leak through git history and block per-environment config. Load from the environment and validate at startup so the app fails fast on missing config. Node 20.6+ has a built-in --env-file, so dotenv is optional. Always add .env to .gitignore and commit a .env.example.
import "dotenv/config"; // or Node 24: node --env-file=.env
import { z } from "zod";
const env = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
}).parse(process.env); // fail fast on invalid config
6. Validate all external input (Zod / Joi)
Never trust request bodies, query params or webhooks. Schema validation stops malformed or malicious data at the boundary and gives you typed data downstream. Validate *and* whitelist — don’t pass raw req.body into a database create; use only the parsed, known fields to prevent mass assignment.
const CreateUser = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(120),
});
app.post("/users", (req, res, next) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error.flatten());
// parsed.data is fully typed
});
7. Use a structured logger (Pino/Winston), not console.log
Production needs leveled, JSON, low-overhead logs you can ship to a collector. Pino is the fast default; console.log is synchronous and unstructured. Never log secrets or PII — configure Pino redact for sensitive fields.
import pino from "pino";
const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
logger.info({ userId, route: "/checkout" }, "order placed");
// ❌ console.log("order placed for " + userId)
8. Structure the app in modules/layers
Separating routes → controllers → services → data access keeps business logic testable and independent of the web framework. Organize by feature/component, not by technical role, and keep framework objects (req/res) out of the service layer so services are unit-testable.
src/
users/ users.routes.js users.service.js users.repo.js
orders/ orders.routes.js orders.service.js orders.repo.js
shared/ logger.js db.js
9. Use ESM (and TypeScript)
ESM is the standard module system, enabling tree-shaking and top-level await, and is what the modern ecosystem (Express 5, Fastify, Hono) ships. TypeScript adds compile-time safety, and Node 24 strips types natively. Note there is no __dirname in ESM — use import.meta.dirname (Node 20.11+).
// package.json
{ "type": "module" }
import { readFile } from "node:fs/promises";
const data = await readFile("config.json", "utf-8"); // top-level await
10. Harden security (Helmet, rate limiting, no eval)
Baseline hardening blocks common web attacks: Helmet sets security headers, rate limiting mitigates brute-force and DoS, and avoiding eval/dynamic require closes injection vectors. Apply Helmet and rate-limiting first, before routes; use parameterized queries (never concatenated SQL); run npm audit in CI.
import helmet from "helmet";
import rateLimit from "express-rate-limit";
app.use(helmet()); // first in the stack
app.use(rateLimit({ windowMs: 15 * 60_000, max: 100 }));
// ❌ eval(userInput) ❌ exec(`ls ${userInput}`)
11. Implement graceful shutdown on SIGTERM/SIGINT
On deploy or scale-down the orchestrator sends SIGTERM; draining in-flight requests and closing DB/Redis connections prevents dropped requests and corrupt state. Always include a timeout fallback — server.close waits for keep-alive connections and can hang forever otherwise.
const server = app.listen(port);
async function shutdown(signal) {
logger.info({ signal }, "shutting down");
server.close(async () => { // stop accepting new connections
await db.end();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref(); // safety net
}
["SIGTERM", "SIGINT"].forEach((s) => process.on(s, () => shutdown(s)));
12. Use streams for large data
Streaming processes data in chunks with backpressure, keeping memory flat; buffering a large file or response into memory can crash the process. Use stream/promises pipeline, not manual .pipe() chains — .pipe() does not forward errors or clean up on failure, leaking file descriptors.
import { pipeline } from "node:stream/promises";
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";
await pipeline(
createReadStream("big.log"),
createGzip(),
createWriteStream("big.log.gz"),
); // backpressure + error propagation handled for you
13. Keep the app stateless; externalize state
Storing sessions or cache in process memory breaks horizontal scaling — requests hitting another instance lose the data, and a restart wipes it. Put shared state in Redis or a database. In-memory caching is fine as a per-instance optimization, but never the source of truth.
// ❌ const sessions = new Map(); // lost on restart, not shared
// ✅
import { createClient } from "redis";
const redis = createClient({ url: env.REDIS_URL });
await redis.set(`session:${id}`, JSON.stringify(data), { EX: 3600 });
14. Run under a supervisor and centralize error handling
A container orchestrator (Kubernetes/Docker) or PM2 restarts crashed processes and runs one instance per core, so your "log and exit" strategy actually recovers. And one central error-handling layer gives consistent responses and logging — in Express 5, async route rejections are auto-forwarded to the error middleware. Don’t leak internal error messages or stack traces to clients.
// Express 5: async errors auto-forward to (err, req, res, next)
app.get("/users/:id", async (req, res) => {
const user = await service.get(req.params.id);
res.json(user);
});
// centralized handler — MUST be registered last, 4 args
app.use((err, req, res, next) => {
logger.error({ err, path: req.path }, "request failed");
res.status(err.status ?? 500).json({ error: err.publicMessage ?? "Internal Error" });
});
15. Keep dependencies updated and audited
Most real-world Node breaches come through vulnerable transitive dependencies, so regular updates plus auditing in CI catch known CVEs before they ship. Commit package-lock.json and use npm ci (not npm install) in CI/Docker for exact, reproducible installs; automate updates with Dependabot or Renovate.
npm audit --audit-level=high # fail CI on high/critical
npm outdated # review updates
npx npm-check-updates -u # bump ranges intentionally
Node.js best practices checklist
- Target the Active LTS (Node 24); use ESM and TypeScript.
- Use async/await with try/catch; never block the event loop (worker_threads for CPU work).
- Handle
unhandledRejection/uncaughtExceptionwith log-and-exit. - Keep secrets in env vars and validate all input (Zod); whitelist fields.
- Use a structured logger (Pino), Helmet, and rate limiting.
- Implement graceful shutdown; use streams for large data; keep the app stateless.
- Centralize error handling, run under a supervisor, and audit dependencies in CI.
Frequently asked questions
Which Node.js version should I use in 2026?
How do I stop Node.js from blocking on heavy work?
worker_threads, ideally a fixed pool sized to os.availableParallelism(). Avoid synchronous APIs like fs.readFileSync and large synchronous JSON.parse in request paths — they freeze every concurrent request.Should I use console.log in production Node.js?
console.log is synchronous and unstructured. Configure redaction so secrets and PII never reach the logs.How do I handle errors globally in Express?
err, req, res, next) as the last middleware. In Express 5, rejected async route handlers are auto-forwarded to it, so you no longer need wrappers like express-async-handler. Log the full error server-side and return a safe message to the client.Prototype Node-style JavaScript instantly in XCODX Studio — run modern JS with a live console, no local setup. Choosing a backend? Compare Node.js vs PHP, and see our JavaScript best practices for the language fundamentals.