Node.js Best Practices

Writing a Node.js script that works on your laptop is easy. Running that same code as a reliable, production service is a different problem: it has to survive crashes, restart cleanly, use your server’s CPU cores efficiently, and never let one slow operation freeze every other request. “Best practices” in Node.js is really a checklist for closing the gap between a script that runs and a service you can trust. This lesson pulls together the practices that matter most, with runnable examples of each.

Overview / How it works

Node.js runs your JavaScript on a single thread inside the V8 engine, with libuv providing the event loop and a small thread pool (four threads by default) for things like file I/O, DNS lookups, and some crypto work. That single JavaScript thread is precious: while it is busy running your code, it cannot accept new connections, process timers, or run any other callback. Every practice in this lesson exists to protect that thread and to make failures visible instead of silent.

In production, a Node process also has to deal with things a local dev server never worries about: environment-specific configuration (database URLs, API keys, ports), abrupt termination signals from the host or orchestrator (Docker, Kubernetes, systemd all send SIGTERM before killing a process), unhandled errors that would otherwise crash the process with no explanation, and the fact that one Node process only uses one CPU core for JavaScript execution — on an 8-core machine, a single node server.js leaves 7 cores idle unless you explicitly use node:cluster or a process manager that forks multiple instances.

The practices below fall into a few buckets: fail fast and loud (validate configuration and catch errors instead of letting bad state propagate silently), never block the event loop (prefer async I/O, offload CPU-heavy work), shut down cleanly (finish in-flight work before exiting), and use your hardware (clustering, process managers, health checks).

Syntax

There isn’t one syntax for “best practices” — it’s a set of recurring patterns you apply throughout an app. The core patterns covered in this lesson:

Pattern Purpose Key API
Config validation Fail on startup, not mid-request, when required env vars are missing process.env
Graceful shutdown Stop accepting new connections, finish in-flight ones, then exit process.on("SIGTERM", ...), server.close()
Centralized error handling Catch async errors in one place instead of repeating try/catch Express error-handling middleware, next(err)
Non-blocking I/O Keep the event loop free to serve other requests node:fs/promises instead of the *Sync functions
Multi-core usage Use every CPU core, not just one node:cluster, or an external process manager

Examples

Example 1: Validate configuration before the server starts

Reading a missing environment variable doesn’t throw — process.env.SOME_VAR just returns undefined, and that undefined quietly flows into your database connection string or port number until something breaks in a confusing way, possibly hours later. Validate everything you need up front, and throw immediately if anything is missing.

const REQUIRED_ENV_VARS = ["PORT", "DATABASE_URL"];

function loadConfig() {
  const missing = REQUIRED_ENV_VARS.filter((key) => !process.env[key]);
  if (missing.length > 0) {
    throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
  }

  return {
    port: Number(process.env.PORT ?? 3000),
    databaseUrl: process.env.DATABASE_URL,
    nodeEnv: process.env.NODE_ENV ?? "development",
  };
}

export const config = loadConfig();

If DATABASE_URL is missing, this throws during module load, before the app ever binds a port — so a bad deploy fails immediately and loudly in your logs or orchestrator, instead of accepting traffic and failing individual requests later.

Example 2: Shut down gracefully on SIGTERM

When a host or container orchestrator wants to stop your process, it sends SIGTERM and then, after a grace period, SIGKILL if the process hasn’t exited. If you don’t handle SIGTERM, Node’s default behavior terminates the process immediately — dropping any in-flight requests. Handling it lets you stop accepting new connections, let existing ones finish, then exit cleanly.

import http from "node:http";
import { config } from "./config.js";

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ status: "ok" }));
});

server.listen(config.port, () => {
  console.log(`Server listening on port ${config.port}`);
});

function shutdown(signal) {
  console.log(`Received ${signal}, closing server...`);
  server.close((err) => {
    if (err) {
      console.error("Error during shutdown:", err);
      process.exit(1);
    }
    console.log("Server closed. Exiting.");
    process.exit(0);
  });

  setTimeout(() => {
    console.error("Forced shutdown after timeout");
    process.exit(1);
  }, 10_000).unref();
}

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

Output:

Server listening on port 3000
# after Ctrl+C:
Received SIGINT, closing server...
Server closed. Exiting.

server.close() stops accepting new connections but waits for existing ones to finish before its callback fires. The setTimeout is a safety net: if a stuck connection never finishes, the process force-exits after 10 seconds instead of hanging forever. .unref() tells Node not to let that timer alone keep the process alive if shutdown already finished.

Example 3: Centralized async error handling in Express

Repeating try/catch in every route handler is easy to forget. Wrapping handlers so thrown errors and rejected promises automatically flow to Express’s error-handling middleware keeps every route consistent.

npm install express
import express from "express";

const app = express();
app.use(express.json());

function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

app.get(
  "/users/:id",
  asyncHandler(async (req, res) => {
    const user = await findUser(req.params.id);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }
    res.status(200).json(user);
  })
);

async function findUser(id) {
  const users = { "1": { id: "1", name: "Ada" } };
  return users[id] ?? null;
}

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: "Internal server error" });
});

app.listen(3000, () => console.log("API listening on port 3000"));

asyncHandler catches both thrown errors and rejected promises from the wrapped function and passes them to next(err), which Express routes to the 4-argument error-handling middleware at the bottom. Without this wrapper, a rejected promise inside an async route handler in older Express versions would produce an unhandled rejection instead of a proper error response.

Under the hood: startup and shutdown order

  1. Top-level module code runs synchronously first — this is why config validation (Example 1) must live at the very top of your entry point, before anything else executes.
  2. server.listen() is asynchronous: it registers the server with the OS and returns immediately; the listening event (and your callback) fires once the OS confirms the port is bound.
  3. The process then sits in the event loop, waiting on the poll phase for incoming connections.
  4. When SIGTERM arrives, Node runs your registered listener synchronously, but the process does not exit on its own — it stays alive because the server is still holding the event loop open with an active listener.
  5. server.close() stops the server from accepting new connections but does not close existing ones; its callback only fires once every existing connection has ended.
  6. Only after that callback (or the timeout fallback) calls process.exit() does the process actually terminate.

Common Mistakes

Mistake 1: Blocking the event loop with synchronous I/O in a request handler

fs.readFileSync blocks the entire process until the file is fully read from disk — every other request, timer, and callback waits. This is fine at startup (reading a config file once) but never inside a request handler.

app.get("/report", (req, res) => {
  const data = fs.readFileSync("./large-report.json", "utf8");
  res.json(JSON.parse(data));
});

Under load, every concurrent request to this route serializes behind the disk read, and no other route on the same process can be served while it runs. Use the promise-based API instead:

app.get("/report", async (req, res, next) => {
  try {
    const data = await fs.readFile("./large-report.json", "utf8");
    res.json(JSON.parse(data));
  } catch (err) {
    next(err);
  }
});

Mistake 2: Using __dirname in an ES module

__dirname and __filename are CommonJS globals; they simply don’t exist inside an ES module (a file using import/export), so referencing them throws a ReferenceError at runtime.

import path from "node:path";

const configPath = path.join(__dirname, "config.json");

In ESM, derive the directory from import.meta.url instead (or use import.meta.dirname directly on Node 20.11+):

import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const configPath = path.join(__dirname, "config.json");

Best Practices

  • Validate all required environment variables at startup and throw immediately if any are missing — never let undefined config silently flow into your app.
  • Never call a *Sync fs/crypto function inside a request handler; use the node:fs/promises API and await it.
  • Handle SIGTERM and SIGINT to close the server and any database pools cleanly before exiting.
  • Listen for process.on("unhandledRejection", ...) and process.on("uncaughtException", ...), log the error, and exit — don’t let the process limp along in an unknown state.
  • Attach an error listener to every stream and event emitter; an emitter with no error listener throws and can crash the process.
  • Use a structured logger (such as pino or winston) instead of console.log in production so logs are timestamped, leveled, and machine-parseable.
  • Use node:cluster, PM2, or your orchestrator’s replica count to run one Node process per CPU core — a single process only uses one core.
  • Keep secrets out of source control; load them from environment variables or a secrets manager, and use dotenv or node --env-file=.env only for local development.
  • Pin dependency versions with a lockfile (package-lock.json) and run npm audit regularly.
  • Expose a lightweight health-check route (GET /health) that orchestrators can poll to know when your process is ready and alive.
  • Set NODE_ENV=production in production — several libraries (including Express) skip expensive debug work when it’s set.

Practice Exercises

  • Take a small Express app you’ve written and add graceful shutdown: handle SIGTERM, close the HTTP server, and only then call process.exit().
  • Write a loadConfig() function that requires PORT, DATABASE_URL, and JWT_SECRET from process.env, throws a clear error listing any missing keys, and returns a typed config object. Test it by unsetting one variable and confirming it throws.
  • Find a route handler that uses fs.readFileSync or JSON.parse on a large file synchronously, and refactor it to use node:fs/promises with proper error handling via try/catch or an asyncHandler wrapper.

Summary

  • Node runs your JavaScript on one thread — blocking it with synchronous I/O or heavy computation stalls every request the process is handling.
  • Validate configuration synchronously at startup so bad deploys fail immediately and loudly, not mid-request.
  • Handle SIGTERM/SIGINT and close the server before exiting so in-flight requests finish instead of being dropped.
  • Centralize error handling (an asyncHandler wrapper plus Express’s error middleware) instead of repeating try/catch in every route.
  • Attach error listeners to streams/emitters and handle unhandledRejection/uncaughtException so failures are visible, not silent.
  • Use node:cluster or a process manager to use every CPU core, and never hardcode secrets in source.