Why centralize error handling?
In a maintainable Express application, every route and middleware should be able to fail without inventing its own response format. A single error-handling middleware gives you: (1) consistent response bodies across the entire API, (2) predictable HTTP status codes, (3) a safe boundary between what clients see and what you log internally, and (4) better debuggability via correlation IDs and structured logs.
The goal is not to hide errors; it is to normalize them into a stable contract for clients while preserving rich diagnostics for operators.
Error taxonomy: operational vs programmer errors
A practical split is:
- Operational errors: expected failures that can happen in production (invalid input, auth failure, resource not found, upstream timeout). These should map to a known status code and a stable client message.
- Programmer errors: bugs (undefined access, invariant violations, unexpected exceptions). These should generally become
500, with minimal client detail, and strong internal logging/alerting.
Centralized handling lets you treat these categories differently without scattering logic across controllers.
Define a normalized error shape
Start by defining an application-level error type that carries the information your handler needs. Keep it small and explicit.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
// errors/AppError.js (CommonJS) or .ts equivalent
class AppError extends Error {
constructor({
code,
message,
status = 500,
isOperational = true,
details,
cause,
}) {
super(message);
this.name = 'AppError';
this.code = code; // stable machine-readable code
this.status = status; // HTTP status
this.isOperational = isOperational;
this.details = details; // optional structured info (safe)
this.cause = cause; // original error (not for clients)
}
}
module.exports = { AppError };Design tips:
codeshould be stable and documented (e.g.,VALIDATION_FAILED,UNAUTHORIZED,UPSTREAM_TIMEOUT).messageshould be safe for clients (no secrets, no SQL, no stack traces).detailsshould be optional and structured (e.g., field errors). Only include it when you are confident it’s safe.causeis for internal debugging/logging; never serialize it directly to clients.
Standardize the response body
Pick a response contract and keep it consistent. One common pattern:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"correlationId": "...",
"details": { ... } // optional
}
}This structure is stable even if internal implementations change. Clients can reliably parse error.code and show error.message.
Correlation IDs: request tracing across logs
A correlation ID ties together all logs for a request and is returned to the client so they can report it. If you already have a request ID middleware, reuse it; otherwise, add a small one.
// middleware/correlationId.js
const crypto = require('crypto');
function correlationId(req, res, next) {
const incoming = req.header('x-correlation-id');
const id = incoming || crypto.randomUUID();
req.correlationId = id;
res.setHeader('x-correlation-id', id);
next();
}
module.exports = { correlationId };Use the correlation ID in logs and include it in error responses.
Step-by-step: build the centralized error handler
Step 1: normalize unknown errors into AppError
Your handler should accept anything (Error, strings, library-specific errors) and convert it into a normalized shape.
// errors/normalizeError.js
const { AppError } = require('./AppError');
function normalizeError(err) {
// Already normalized
if (err instanceof AppError) return err;
// Example: JSON parse errors from express.json()
if (err && err.type === 'entity.parse.failed') {
return new AppError({
code: 'INVALID_JSON',
message: 'Malformed JSON body',
status: 400,
isOperational: true,
cause: err,
});
}
// Fallback: treat as programmer error
return new AppError({
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
status: 500,
isOperational: false,
cause: err,
});
}
module.exports = { normalizeError };Step 2: map common categories (validation, auth, upstream)
Instead of letting each route decide status codes, create mapping helpers that convert known error sources into AppError.
Validation errors
Validation libraries differ, but the mapping goal is the same: return 400 with a stable code and field-level details.
// errors/mappers/mapValidationError.js
const { AppError } = require('../AppError');
function mapValidationError(err) {
// Example shape: { name: 'ValidationError', issues: [{ path, message }] }
if (!err || err.name !== 'ValidationError') return null;
const fieldErrors = {};
for (const issue of err.issues || []) {
const key = Array.isArray(issue.path) ? issue.path.join('.') : String(issue.path);
fieldErrors[key] = issue.message;
}
return new AppError({
code: 'VALIDATION_FAILED',
message: 'Request validation failed',
status: 400,
isOperational: true,
details: { fields: fieldErrors },
cause: err,
});
}
module.exports = { mapValidationError };Authentication/authorization errors
Auth failures should not leak whether a user exists or whether a token is close to valid. Keep messages generic; use codes for client behavior.
// errors/mappers/mapAuthError.js
const { AppError } = require('../AppError');
function mapAuthError(err) {
if (!err) return null;
if (err.name === 'UnauthorizedError' || err.code === 'INVALID_TOKEN') {
return new AppError({
code: 'UNAUTHORIZED',
message: 'Authentication required',
status: 401,
isOperational: true,
cause: err,
});
}
if (err.code === 'FORBIDDEN') {
return new AppError({
code: 'FORBIDDEN',
message: 'You do not have access to this resource',
status: 403,
isOperational: true,
cause: err,
});
}
return null;
}
module.exports = { mapAuthError };Upstream failures (HTTP calls, queues, databases behind a gateway)
When a dependency fails, clients need a stable error and (sometimes) a hint to retry. Internally, you want the upstream status, timeout, and response body (if safe) in logs.
// errors/mappers/mapUpstreamError.js
const { AppError } = require('../AppError');
function mapUpstreamError(err) {
// Example: fetch/axios-like error shapes
const isTimeout = err && (err.code === 'ETIMEDOUT' || err.code === 'ECONNABORTED');
if (isTimeout) {
return new AppError({
code: 'UPSTREAM_TIMEOUT',
message: 'A dependent service did not respond in time',
status: 504,
isOperational: true,
cause: err,
});
}
const upstreamStatus = err && (err.response?.status || err.status);
if (upstreamStatus) {
// Map 5xx from upstream to 502; 4xx can be 502 or 424 depending on semantics.
const status = upstreamStatus >= 500 ? 502 : 502;
return new AppError({
code: 'UPSTREAM_FAILURE',
message: 'A dependent service returned an error',
status,
isOperational: true,
details: { upstreamStatus },
cause: err,
});
}
return null;
}
module.exports = { mapUpstreamError };Step 3: implement the single error-handling middleware
This middleware should: (1) normalize/map errors, (2) decide what to expose based on environment, (3) log with correlation ID, and (4) send the stable response body.
// middleware/errorHandler.js
const { normalizeError } = require('../errors/normalizeError');
const { mapValidationError } = require('../errors/mappers/mapValidationError');
const { mapAuthError } = require('../errors/mappers/mapAuthError');
const { mapUpstreamError } = require('../errors/mappers/mapUpstreamError');
function errorHandler({ logger, env }) {
const isDev = env === 'development';
return function (err, req, res, next) {
// If headers already sent, delegate to Express default handler
if (res.headersSent) return next(err);
// Map known error sources first
const mapped =
mapValidationError(err) ||
mapAuthError(err) ||
mapUpstreamError(err);
const appErr = normalizeError(mapped || err);
const correlationId = req.correlationId;
// Logging: always log stack/cause internally; never rely on client output
const logPayload = {
correlationId,
code: appErr.code,
status: appErr.status,
isOperational: appErr.isOperational,
path: req.originalUrl,
method: req.method,
};
// Attach diagnostic info carefully
if (appErr.cause) {
logPayload.causeName = appErr.cause.name;
logPayload.causeMessage = appErr.cause.message;
logPayload.stack = appErr.cause.stack;
} else {
logPayload.stack = appErr.stack;
}
// Use severity based on type
if (appErr.isOperational) logger.warn(logPayload);
else logger.error(logPayload);
// Client response: stable and safe
const body = {
error: {
code: appErr.code,
message: appErr.message,
correlationId,
},
};
// Optional: include safe details for operational errors
if (appErr.details && appErr.isOperational) {
body.error.details = appErr.details;
}
// Development-only: include debug fields (still avoid secrets)
if (isDev) {
body.error.debug = {
status: appErr.status,
name: appErr.name,
};
// You may include stack in dev if your environment is trusted
body.error.debug.stack = (appErr.cause && appErr.cause.stack) || appErr.stack;
}
res.status(appErr.status).json(body);
};
}
module.exports = { errorHandler };Step 4: wire it in once, at the end
Register the correlation ID middleware early (so all logs include it) and the error handler last (so it catches errors from everything before it).
// app.js
const express = require('express');
const { correlationId } = require('./middleware/correlationId');
const { errorHandler } = require('./middleware/errorHandler');
const logger = console; // replace with pino/winston adapter
const app = express();
app.use(correlationId);
app.use(express.json());
// ... routes here ...
app.use(errorHandler({ logger, env: process.env.NODE_ENV || 'development' }));
module.exports = { app };Consistent status code strategy
Centralization is where you enforce status code rules. A simple, consistent mapping table helps:
| Category | Example code | Status | Client message style |
|---|---|---|---|
| Validation | VALIDATION_FAILED | 400 | Generic + field details |
| Auth required | UNAUTHORIZED | 401 | Generic |
| Forbidden | FORBIDDEN | 403 | Generic |
| Not found | NOT_FOUND | 404 | Generic |
| Conflict | CONFLICT | 409 | Generic |
| Rate limited | RATE_LIMITED | 429 | Retry hint |
| Upstream timeout | UPSTREAM_TIMEOUT | 504 | Retry hint |
| Upstream failure | UPSTREAM_FAILURE | 502 | Generic |
| Bug/unexpected | INTERNAL_ERROR | 500 | Generic |
Keep the mapping stable; clients should not need to change because you swapped a library or refactored internals.
Safe client messages vs internal details
Rules of thumb for what never goes to clients
- Stack traces (except in tightly controlled development environments).
- Raw upstream response bodies (may contain secrets or internal structure).
- SQL queries, connection strings, file paths, tokens, API keys.
- Anything derived from
err.messageunless you control it.
What can go to clients (when operational)
- Stable
error.codefor programmatic handling. - A short, generic
error.message. - Structured validation details (field-level messages) that you generate yourself.
correlationIdfor support and debugging.
A useful practice is to treat details as “safe-by-construction”: only populate it from your own mapping functions, not from arbitrary thrown errors.
Logging guidance: stack traces without unstable responses
Centralized handling is the right place to guarantee that every error is logged with the same core fields. Prefer structured logs so you can search and aggregate by correlationId, code, and status.
// Example log payload shape
{
"level": "error",
"correlationId": "b7c0...",
"code": "INTERNAL_ERROR",
"status": 500,
"method": "POST",
"path": "/api/orders",
"stack": "..."
}Keep responses stable even if logging changes: logging can evolve (more fields, different logger), but the client response contract should remain the same. This separation prevents accidental breaking changes.
Patterns for common cases
Mapping “not found” consistently
Instead of returning different 404 bodies from different routes, throw a normalized error when a resource is missing.
// errors/factories.js
const { AppError } = require('./AppError');
function notFound(message = 'Resource not found') {
return new AppError({
code: 'NOT_FOUND',
message,
status: 404,
isOperational: true,
});
}
module.exports = { notFound };Use it wherever needed; the handler will do the rest.
Handling upstream failures with retry semantics
If you want clients to retry, keep the response stable and use headers rather than changing the body format. For example, for rate limiting or temporary upstream issues you can set Retry-After in the handler when code matches.
// inside errorHandler before res.status(...)
if (appErr.code === 'RATE_LIMITED' && appErr.details?.retryAfterSeconds) {
res.setHeader('Retry-After', String(appErr.details.retryAfterSeconds));
}Environment-based output: development vs production
In production, the response should be minimal and safe. In development, you can include debug fields to speed up iteration. The key is that the top-level structure remains the same so clients and tests don’t break.
- Production:
{ error: { code, message, correlationId, details? } } - Development: same as production, plus
error.debug(stack/name/status)
If you run “development-like” environments that are still exposed (shared staging), consider disabling stack traces there as well and rely on logs.
Stability checklist for centralized error handling
- All errors pass through one handler that always returns the same JSON envelope.
- Every response includes a correlation ID and every log line includes the same ID.
- Operational errors have stable codes and appropriate status codes.
- Programmer errors become
500with generic client messages. - Validation/auth/upstream errors are mapped explicitly (not by parsing random messages).
- Stack traces are logged, not returned (except optionally in trusted development).
- Changes to internal libraries do not change client-visible error shapes.