Most Node.js tutorials stop at res.status(500).send("Something went wrong"). That line is fine for a weekend project — it is a liability in production. Real APIs fail in dozens of ways: databases time out, third-party services return garbage, users send malformed payloads, and JWT secrets rotate at the worst possible moment. If your error handling is scattered across route handlers, your clients get inconsistent responses, your on-call engineer gets woken up without context, and — worst of all — raw stack traces leak internals to anyone watching network traffic.
This article lays out a production-grade pattern for Express-based REST APIs that centralises error handling, enforces a consistent response contract, and logs the right information without exposing it.
The Core Principle: Errors Are First-Class Citizens
Treat errors the same way you treat successful responses — with a defined shape, a clear owner, and deliberate logging. Every error that escapes a route handler should pass through a single Express error-handling middleware before it ever touches res. This gives you one place to sanitise, log, and format.
Step 1 — Build a Custom Error Class Hierarchy
The built-in Error object carries a message and a stack trace. That is not enough. You need an HTTP status code, a machine-readable error code, and a flag that distinguishes operational errors (expected failures you can explain to the client) from programmer errors (bugs that should page your team).
// src/errors/AppError.js
class AppError extends Error {
constructor(message, statusCode, errorCode, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode; // e.g. "RESOURCE_NOT_FOUND"
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = "Resource") {
super(`${resource} not found`, 404, "RESOURCE_NOT_FOUND");
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 422, "VALIDATION_ERROR");
}
}
class UnauthorisedError extends AppError {
constructor() {
super("Authentication required", 401, "UNAUTHORISED");
}
}
module.exports = { AppError, NotFoundError, ValidationError, UnauthorisedError };
Having named subclasses means route handlers communicate intent, not just status codes. throw new NotFoundError("Order") is far more readable than return res.status(404).json(...) scattered across fifty files.
Step 2 — Centralised Error-Handling Middleware
Express recognises a four-argument middleware as an error handler. Register it last, after all routes.
// src/middleware/errorHandler.js
const { AppError } = require("../errors/AppError");
const logger = require("../utils/logger");
function errorHandler(err, req, res, next) {
// Normalise unknown errors into a safe AppError
const isOperational = err instanceof AppError && err.isOperational;
const statusCode = err.statusCode || 500;
const errorCode = err.errorCode || "INTERNAL_ERROR";
const message = isOperational
? err.message
: "An unexpected error occurred"; // never leak programmer errors
// Structured log — include request context for traceability
logger[isOperational ? "warn" : "error"]({
errorCode,
message: err.message, // full message goes to logs, not client
stack: err.stack,
method: req.method,
path: req.originalUrl,
requestId: req.id, // set by express-request-id or similar
});
res.status(statusCode).json({
success: false,
errorCode,
message,
requestId: req.id, // lets clients correlate errors with support tickets
});
}
module.exports = errorHandler;
Two things to notice here. First, programmer errors (isOperational === false) receive a generic client message — the real message goes only to the log. Second, requestId appears in both the log entry and the response body. This single field transforms debugging from archaeology into a simple log search.
Step 3 — Catching Async Errors Without Repetition
Express does not catch promise rejections thrown inside async route handlers by default (versions before Express 5). Wrapping every handler in try/catch is noise. Use a thin wrapper instead.
// src/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
Now route handlers stay clean:
router.get("/orders/:id", asyncHandler(async (req, res) => {
const order = await OrderService.findById(req.params.id);
if (!order) throw new NotFoundError("Order");
res.json({ success: true, data: order });
}));
No try/catch, no next(err) boilerplate. Any thrown error — whether an AppError or an unexpected TypeError — flows straight to the centralised handler.
Step 4 — Structured Logging With Context
console.error is not logging — it is shouting into a void. In production you need structured, queryable logs. Use a library like Pino or Winston configured to emit JSON.
Key fields every error log entry should carry:
timestamp— ISO 8601, always UTClevel—warnfor operational errors,errorfor programmer errorsrequestId— correlates with the client-facing responseuserId— if available from the auth layererrorCodeandmessage— for alerting rules and dashboardsstack— in non-production environments; conditionally strip it in production or ship it to a separate, access-controlled log stream
Do not log sensitive fields — passwords, card numbers, raw JWT tokens — even in error context. If a validation error includes user input, log the field name, not the value.
Step 5 — Handle the Unhandled
Two Node.js process events catch what Express misses.
process.on("unhandledRejection", (reason) => {
logger.error({ msg: "Unhandled promise rejection", reason });
// Give the process a moment to flush logs, then exit.
// A process manager (PM2, Kubernetes) will restart it.
process.exit(1);
});
process.on("uncaughtException", (err) => {
logger.fatal({ msg: "Uncaught exception", err });
process.exit(1);
});
Attempting to continue after an uncaught exception is dangerous — the process may be in an inconsistent state. Exit fast, log everything, and let your orchestration layer restart the service. This is not pessimism; it is the Unix philosophy applied to reliability.
The Response Contract Your Clients Will Thank You For
Every response — success or failure — should share a consistent envelope:
| Field | Success | Error |
|---|---|---|
success | true | false |
data | payload | omitted |
errorCode | omitted | machine-readable code |
message | omitted | human-readable string |
requestId | optional | always present |
Frontend teams, mobile developers, and third-party integrators can all write a single if (!response.success) guard and trust that errorCode will always be there. This contract, more than any single piece of code, is what makes an API feel professional.
Why This Matters for Your Project
Whether you are shipping a fintech backend in Accra or a SaaS platform serving clients across three continents, error handling is where trust is won or lost. A stack trace in a 500 response is a security incident waiting to happen. A vague "something went wrong" message with no correlation ID turns a five-minute bug fix into a two-hour support call. The architecture above — custom error classes, centralised middleware, structured logs, and a consistent response envelope — costs roughly half a day to implement and pays dividends every time something goes wrong in production. And something always goes wrong in production.





