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
- 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.
server.listen()is asynchronous: it registers the server with the OS and returns immediately; thelisteningevent (and your callback) fires once the OS confirms the port is bound.- The process then sits in the event loop, waiting on the poll phase for incoming connections.
- When
SIGTERMarrives, 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. 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.- 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
undefinedconfig silently flow into your app. - Never call a
*Syncfs/crypto function inside a request handler; use thenode:fs/promisesAPI andawaitit. - Handle
SIGTERMandSIGINTto close the server and any database pools cleanly before exiting. - Listen for
process.on("unhandledRejection", ...)andprocess.on("uncaughtException", ...), log the error, and exit — don’t let the process limp along in an unknown state. - Attach an
errorlistener to every stream and event emitter; an emitter with noerrorlistener throws and can crash the process. - Use a structured logger (such as
pinoorwinston) instead ofconsole.login 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
dotenvornode --env-file=.envonly for local development. - Pin dependency versions with a lockfile (
package-lock.json) and runnpm auditregularly. - Expose a lightweight health-check route (
GET /health) that orchestrators can poll to know when your process is ready and alive. - Set
NODE_ENV=productionin 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 callprocess.exit(). - Write a
loadConfig()function that requiresPORT,DATABASE_URL, andJWT_SECRETfromprocess.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.readFileSyncorJSON.parseon a large file synchronously, and refactor it to usenode:fs/promiseswith proper error handling viatry/catchor anasyncHandlerwrapper.
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/SIGINTand close the server before exiting so in-flight requests finish instead of being dropped. - Centralize error handling (an
asyncHandlerwrapper plus Express’s error middleware) instead of repeating try/catch in every route. - Attach
errorlisteners to streams/emitters and handleunhandledRejection/uncaughtExceptionso failures are visible, not silent. - Use
node:clusteror a process manager to use every CPU core, and never hardcode secrets in source.
