Blog

Blog · Best Practices

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.

XCODX Team · 7 min read

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/uncaughtException with 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?
Target Node 24 "Krypton" — the Active LTS — for new projects; it gets security patches through 2028 and strips TypeScript types natively. Keep existing production on Node 22 (Maintenance LTS) until dependencies are verified. Node 20 reached end of life in 2026, so upgrade off it.
How do I stop Node.js from blocking on heavy work?
Move CPU-bound work (hashing, parsing, image processing) off the main thread into 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?
No. Use a structured logger like Pino or Winston that emits leveled JSON you can ship to a log collector. console.log is synchronous and unstructured. Configure redaction so secrets and PII never reach the logs.
How do I handle errors globally in Express?
Register a centralized error-handling middleware (four arguments: 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.