Skip to main content

Error Handling ๐Ÿšจ

Error handling is the discipline of anticipating, detecting, and gracefully recovering from failures in your application. Good error handling prevents crashes, provides meaningful feedback to clients, and simplifies debugging โ€” turning chaotic failures into structured, observable events.

Errors are not exceptions to the happy path. In distributed systems, errors are the happy path.


Operational vs Programmer Errorsโ€‹

The most important distinction in error handling is between operational errors and programmer errors. Treating them the same way leads to fragile systems.

DimensionOperational ErrorsProgrammer Errors
DefinitionExpected failures in the runtime environmentBugs and logic mistakes in the code
CauseNetwork timeouts, invalid user input, DB connection failures, disk full, 3rd-party API downtimeTypeError, undefined variable, null reference, off-by-one, promise not awaited
Can we handle?โœ… Yes โ€” these should be caught and handled gracefullyโŒ No safe recovery โ€” the application state may be corrupted
ResponseReturn a structured error to the client (4xx/5xx)Let the process crash. Restart it with a process manager (PM2, Kubernetes)
ExamplesECONNREFUSED, ETIMEDOUT, validation failures, rate limits, duplicate key violationsCannot read property 'x' of undefined, uncaughtException, infinite recursion

The Golden Ruleโ€‹

Handle operational errors. Don't handle programmer errors โ€” fix them.

// โŒ DON'T โ€” catching programmer errors to prevent a crash
try {
const user = await db.users.findById(id);
console.log(user.name.first); // if user is null, this throws โ€” and it's a BUG
} catch (err) {
// Swallowing the error hides the bug. The function that called this
// should have validated `user` before accessing nested properties.
return null;
}

// โœ… DO โ€” distinguish between expected and unexpected
try {
const user = await db.users.findById(id);
if (!user) {
throw new NotFoundError('User not found'); // operational
}
return user;
} catch (err) {
if (err instanceof AppError) {
// operational: return structured error
return res.status(err.statusCode).json(err.toJSON());
}
// programmer: let it bubble up โ€” or crash intentionally
throw err;
}

Structured Error Responsesโ€‹

Clients (and your own frontend) should never have to parse error messages with regex. Use a consistent, machine-readable error envelope.

Error Envelope Contractโ€‹

{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more validation errors occurred.",
"details": [
{
"field": "email",
"message": "Must be a valid email address.",
"received": "notanemail"
}
],
"requestId": "req_abc123",
"timestamp": "2025-01-20T10:30:00.000Z"
}
}

Field-by-field Breakdownโ€‹

FieldTypeRequiredDescription
successbooleanโœ…Always false for errors. Makes client branching trivial: if (!res.success) { ... }
error.codestringโœ…Machine-readable, UPPER_SNAKE_CASE identifier (e.g., INVALID_INPUT, RATE_LIMITED, DB_UNAVAILABLE)
error.messagestringโœ…Human-readable summary. Safe for display to end users in production.
error.detailsarrayโŒField-level errors, validation failures, or additional context. Must be an array for consistency.
error.requestIdstringโœ…Correlation ID โ€” ties the error to a specific request in your logs.
error.timestampstringโœ…ISO 8601 timestamp of when the error was generated.

Anti-patterns to Avoidโ€‹

// โŒ String message โ€” impossible to branch programmatically
{ "error": "User not found" }

// โŒ HTML error pages in APIs
"<html><body><h1>500 Internal Server Error</h1></body></html>"

// โŒ Exposing stack traces in production
{
"error": "TypeError: Cannot read property 'id' of null\n at UserController.get (/app/src/controllers/user.ts:42:15)"
}

// โŒ Inconsistent shapes
GET /users/1 โ†’ { "error": "Not found" }
POST /users โ†’ { "message": "Validation failed", "errors": [...] }
// Two different error shapes from the same API โ€” the client now needs two parsers.

Custom Error Classesโ€‹

Plain Error objects don't carry enough context. Build a hierarchy of custom error classes that encapsulate HTTP status codes, error codes, and serialization logic.

Base Application Errorโ€‹

// errors/AppError.js
class AppError extends Error {
/**
* @param {string} message - Human-readable description
* @param {number} statusCode - HTTP status code
* @param {string} code - Machine-readable error code
* @param {Array} details - Optional field-level details
*/
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = []) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.timestamp = new Date().toISOString();
this.isOperational = true; // flag for distinguishing from programmer errors
Error.captureStackTrace(this, this.constructor);
}

toJSON() {
return {
success: false,
error: {
code: this.code,
message: this.message,
details: this.details,
timestamp: this.timestamp,
},
};
}
}

module.exports = AppError;

Specific Error Subclassesโ€‹

// errors/NotFoundError.js
const AppError = require('./AppError');

class NotFoundError extends AppError {
constructor(message = 'Resource not found', details = []) {
super(message, 404, 'NOT_FOUND', details);
}
}

// errors/ValidationError.js
class ValidationError extends AppError {
constructor(message = 'Validation failed', details = []) {
super(message, 422, 'VALIDATION_ERROR', details);
}
}

// errors/UnauthorizedError.js
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required', details = []) {
super(message, 401, 'UNAUTHORIZED', details);
}
}

// errors/ForbiddenError.js
class ForbiddenError extends AppError {
constructor(message = 'Insufficient permissions', details = []) {
super(message, 403, 'FORBIDDEN', details);
}
}

// errors/ConflictError.js
class ConflictError extends AppError {
constructor(message = 'Resource already exists', details = []) {
super(message, 409, 'CONFLICT', details);
}
}

// errors/TooManyRequestsError.js
class TooManyRequestsError extends AppError {
constructor(message = 'Rate limit exceeded', retryAfter = 60, details = []) {
super(message, 429, 'RATE_LIMITED', details);
this.retryAfter = retryAfter;
}

toJSON() {
return {
...super.toJSON(),
retryAfter: this.retryAfter,
};
}
}

// errors/ServiceUnavailableError.js
class ServiceUnavailableError extends AppError {
constructor(message = 'Service temporarily unavailable', details = []) {
super(message, 503, 'SERVICE_UNAVAILABLE', details);
}
}

Using Custom Errors in Business Logicโ€‹

async function getUserById(id) {
const user = await db.users.findById(id);
if (!user) {
throw new NotFoundError(`User with ID ${id} not found`);
}
return user;
}

async function createUser(data) {
const existing = await db.users.findByEmail(data.email);
if (existing) {
throw new ConflictError('A user with this email already exists', [
{ field: 'email', message: 'Email is already registered' },
]);
}
return db.users.create(data);
}

Express Error Middlewareโ€‹

Express distinguishes between regular middleware (3 parameters: req, res, next) and error-handling middleware (4 parameters: err, req, res, next). Error-handling middleware is only invoked when next(err) is called or a synchronous error is thrown.

Basic Error Handlerโ€‹

// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
// If headers already sent, delegate to default Express handler
if (res.headersSent) {
return next(err);
}

// Determine if this is an operational error we can handle
if (err.isOperational) {
const statusCode = err.statusCode || 500;
const body = err.toJSON();

// Attach correlation ID from request (set by earlier middleware)
body.error.requestId = req.requestId;

return res.status(statusCode).json(body);
}

// Programmer error โ€” log the full stack, return generic 500
console.error('๐Ÿ’ฅ UNEXPECTED ERROR:', err);
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId: req.requestId,
timestamp: new Date().toISOString(),
},
});
}

Production vs Development Error Formatโ€‹

function errorHandler(err, req, res, next) {
if (res.headersSent) return next(err);

// Operational errors: safe to expose details
if (err.isOperational) {
const body = err.toJSON?.() ?? {
success: false,
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.message,
timestamp: new Date().toISOString(),
},
};
body.error.requestId = req.requestId;
return res.status(err.statusCode || 500).json(body);
}

// In development, include the stack trace for debugging
if (process.env.NODE_ENV === 'development') {
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: err.message,
stack: err.stack,
requestId: req.requestId,
timestamp: new Date().toISOString(),
},
});
}

// In production, never leak internals
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId: req.requestId,
timestamp: new Date().toISOString(),
},
});
}

Registration Order Mattersโ€‹

const express = require('express');
const app = express();

// 1. Request-scoped middleware (body parsing, logging, correlation IDs)
app.use(express.json());
app.use(attachRequestId);
app.use(requestLogger);

// 2. Routes
app.use('/api/users', userRoutes);
app.use('/api/orders', orderRoutes);

// 3. 404 handler โ€” after all routes, before error handler
app.use((req, res, next) => {
next(new NotFoundError(`Route ${req.method} ${req.originalUrl} not found`));
});

// 4. Error handler โ€” MUST have 4 parameters, MUST be registered LAST
app.use(errorHandler);

Async Error Handlingโ€‹

Express 4.x does not catch rejected promises or thrown errors inside async route handlers by default. An unhandled rejection in an async handler hangs the request forever.

The Problemโ€‹

// โŒ This will hang the request โ€” Express 4.x doesn't catch the rejection
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
// If db throws/rejects, Express never sends a response
res.json(user);
});

// โŒ This also hangs
app.get('/users/:id', (req, res) => {
someAsyncFunction().then((user) => res.json(user));
// If someAsyncFunction rejects, the promise chain is broken
});

Solution 1: Async Handler Wrapperโ€‹

// middleware/asyncHandler.js
/**
* Wraps an async route handler to catch rejections and forward them to next().
* @param {Function} fn - Async route handler
* @returns {Function} Express middleware
*/
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}

// Usage
const asyncHandler = require('./middleware/asyncHandler');
const { getUserById } = require('./controllers/userController');

app.get(
'/users/:id',
asyncHandler(async (req, res) => {
const user = await getUserById(req.params.id);
res.json({ success: true, data: user });
}),
);

Solution 2: Use a Libraryโ€‹

// express-async-errors (monkey-patches express to handle async errors)
require('express-async-errors');

// Now async handlers work without wrapping
app.get('/users/:id', async (req, res) => {
const user = await getUserById(req.params.id); // rejection โ†’ next(err)
res.json({ success: true, data: user });
});

Solution 3: NestJS / Decorator-based (TypeScript)โ€‹

// NestJS handles async errors natively โ€” no wrapper needed
@Controller('users')
export class UserController {
@Get(':id')
async getUser(@Param('id') id: string) {
const user = await this.userService.findById(id);
// If this throws, NestJS catches it and routes to its exception filter
return { success: true, data: user };
}
}

Centralized Error Handling Patternโ€‹

Here's a complete, modular error handling architecture for a typical Express + TypeScript application:

Directory Structureโ€‹

src/
โ”œโ”€โ”€ errors/
โ”‚ โ”œโ”€โ”€ AppError.ts // Base class
โ”‚ โ”œโ”€โ”€ NotFoundError.ts // 404
โ”‚ โ”œโ”€โ”€ ValidationError.ts // 422
โ”‚ โ”œโ”€โ”€ UnauthorizedError.ts // 401
โ”‚ โ”œโ”€โ”€ ForbiddenError.ts // 403
โ”‚ โ”œโ”€โ”€ ConflictError.ts // 409
โ”‚ โ””โ”€โ”€ index.ts // Barrel export
โ”œโ”€โ”€ middleware/
โ”‚ โ”œโ”€โ”€ errorHandler.ts // Central error-handling middleware
โ”‚ โ”œโ”€โ”€ asyncHandler.ts // Async wrapper
โ”‚ โ””โ”€โ”€ notFound.ts // 404 catch-all
โ”œโ”€โ”€ utils/
โ”‚ โ””โ”€โ”€ logger.ts // Structured logger (Winston/Pino)
โ””โ”€โ”€ app.ts // Express setup

App Setup (TypeScript)โ€‹

// app.ts
import express from 'express';
import { attachRequestId } from './middleware/requestId';
import { errorHandler } from './middleware/errorHandler';
import { notFound } from './middleware/notFound';
import { asyncHandler } from './middleware/asyncHandler';
import { NotFoundError } from './errors';

const app = express();

// --- Request-scoped middleware ---
app.use(express.json({ limit: '1mb' }));
app.use(attachRequestId);

// --- Routes ---
app.use('/api/users', userRoutes);
app.use('/api/orders', orderRoutes);

// Health check (before 404 handler)
app.get('/health', (req, res) => res.json({ status: 'ok' }));

// --- 404 catch-all ---
app.use(notFound);

// --- Central error handler (always last) ---
app.use(errorHandler);

export default app;

Async Handler (TypeScript)โ€‹

// middleware/asyncHandler.ts
import { Request, Response, NextFunction } from 'express';

type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<any>;

export function asyncHandler(fn: AsyncHandler) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}

404 Catch-Allโ€‹

// middleware/notFound.ts
import { Request, Response, NextFunction } from 'express';
import { NotFoundError } from '../errors';

export function notFound(req: Request, res: Response, next: NextFunction) {
next(new NotFoundError(`Route ${req.method} ${req.originalUrl} not found`));
}

Controller Usage Exampleโ€‹

// controllers/userController.ts
import { asyncHandler } from '../middleware/asyncHandler';
import { NotFoundError, ValidationError } from '../errors';
import * as userService from '../services/userService';

export const getUserById = asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) {
throw new NotFoundError(`User with ID ${req.params.id} not found`);
}
res.json({ success: true, data: user });
});

export const createUser = asyncHandler(async (req, res) => {
const { email, name } = req.body;

const errors = [];
if (!email) errors.push({ field: 'email', message: 'Email is required' });
if (!name) errors.push({ field: 'name', message: 'Name is required' });

if (errors.length > 0) {
throw new ValidationError('Validation failed', errors);
}

const user = await userService.create({ email, name });
res.status(201).json({ success: true, data: user });
});

Correlation IDs for Loggingโ€‹

In a distributed system, a single user request may touch multiple services. Without a shared identifier, tracing that request across logs is impossible. A correlation ID (also called request ID, trace ID) solves this.

How It Worksโ€‹

sequenceDiagram
participant Client
participant API Gateway
participant User Service
participant Order Service
participant Payment Service

Client->>API Gateway: GET /orders/42 (X-Request-Id: abc123)
API Gateway->>User Service: GET /users/5 (X-Request-Id: abc123)
User Service-->>API Gateway: user data
API Gateway->>Order Service: GET /orders/42 (X-Request-Id: abc123)
Order Service->>Payment Service: GET /payments?order_id=42 (X-Request-Id: abc123)
Payment Service-->>Order Service: payment data
Order Service-->>API Gateway: order data
API Gateway-->>Client: response

Every service logs with the same correlation ID โ€” searching abc123 in your log aggregator reconstructs the entire request journey.

Middleware: Attach Correlation IDโ€‹

// middleware/requestId.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';

export function attachRequestId(req: Request, res: Response, next: NextFunction) {
// Accept incoming ID from upstream (X-Request-Id header) or generate one
const requestId = (req.headers['x-request-id'] as string) || uuidv4();

// Attach to request object for downstream use
req.requestId = requestId;

// Echo back to client in response header
res.setHeader('X-Request-Id', requestId);

next();
}

// Type augmentation
declare global {
namespace Express {
interface Request {
requestId: string;
}
}
}

Propagate to Outgoing Requestsโ€‹

// utils/httpClient.ts
import axios from 'axios';
import { getCurrentRequestId } from './asyncContext';

const httpClient = axios.create();

httpClient.interceptors.request.use((config) => {
const requestId = getCurrentRequestId();
if (requestId) {
config.headers['X-Request-Id'] = requestId;
}
return config;
});

export default httpClient;

Async Context (Node.js AsyncLocalStorage)โ€‹

// utils/asyncContext.ts
import { AsyncLocalStorage } from 'async_hooks';

const asyncContext = new AsyncLocalStorage<{ requestId: string }>();

export function runWithContext(requestId: string, fn: () => void) {
asyncContext.run({ requestId }, fn);
}

export function getCurrentRequestId(): string | undefined {
return asyncContext.getStore()?.requestId;
}

Wire It All Togetherโ€‹

// middleware/requestContext.ts
import { Request, Response, NextFunction } from 'express';
import { runWithContext } from '../utils/asyncContext';

export function requestContext(req: Request, res: Response, next: NextFunction) {
runWithContext(req.requestId, () => {
next();
});
}

Now every log statement anywhere in the call tree can include the correlation ID without passing it explicitly through every function parameter.


HTTP Status Code Selectionโ€‹

Choosing the right status code is part of your API's contract. Consistency matters more than perfection โ€” pick a convention and stick with it.

Decision Flowchartโ€‹

flowchart TD
A[Error occurred] --> B{Is the request valid?}
B -->|No| C{Why?}
C -->|Malformed JSON / bad syntax| D[400 Bad Request]
C -->|Missing/expired auth| E[401 Unauthorized]
C -->|Authenticated but no permission| F[403 Forbidden]
C -->|Validation failure| G[422 Unprocessable Entity]
C -->|Rate limited| H[429 Too Many Requests]
C -->|Payload too large| I[413 Payload Too Large]

B -->|Yes| J{Does the resource exist?}
J -->|No| K[404 Not Found]
J -->|Yes| L{Is there a conflict?}
L -->|Duplicate / state conflict| M[409 Conflict]
L -->|Precondition failed| N[412 Precondition Failed]

L -->|No| O{Is it the server's fault?}
O -->|Database down| P[503 Service Unavailable]
O -->|Upstream timeout| Q[504 Gateway Timeout]
O -->|Unexpected crash| R[500 Internal Server Error]
O -->|Not implemented| S[501 Not Implemented]

Quick Reference Tableโ€‹

ScenarioStatus CodeNotes
Missing required field400 Bad RequestSimple input errors
Invalid JSON syntax400 Bad RequestMalformed request body
Wrong Content-Type415 Unsupported Media TypeExpected application/json, got text/plain
Missing Authorization header401 UnauthorizedTechnically means "unauthenticated"
Wrong credentials / expired token401 Unauthorized
User lacks role/permission403 ForbiddenRe-authenticating won't help
Resource not found404 Not FoundPrefer 404 over 403 for existence checks (prevents enumeration)
Method not allowed405 Method Not AllowedPOST on a GET-only endpoint
If-Match / If-None-Match failure412 Precondition FailedOptimistic concurrency
Duplicate unique field409 Conflicte.g., duplicate email during registration
Business rule violation409 Conflicte.g., cancelling an already-shipped order
Semantic validation failure422 Unprocessable EntityWell-formed request, but doesn't pass business rules
Rate limit exceeded429 Too Many RequestsInclude Retry-After header
Database connection failure503 Service UnavailableInclude Retry-After if known
Upstream API timeout504 Gateway TimeoutYour server acting as a gateway
Unexpected null pointer500 Internal Server ErrorNever expose stack in production

401 vs 403 โ€” The Subtletyโ€‹

401 Unauthorized means "I don't know who you are" (unauthenticated).
403 Forbidden means "I know who you are, but you're not allowed" (unauthorized).

If the client sent no credentials โ†’ 401. If the client sent valid credentials but lacks permission โ†’ 403.

When to Use 200 vs 201 vs 202 vs 204โ€‹

StatusMeaningWhen
200 OKSuccess with bodyGET, PUT, PATCH success
201 CreatedResource createdPOST success โ€” include Location header
202 AcceptedAccepted for async processingLong-running operation โ€” body may include a status URL
204 No ContentSuccess, no bodyDELETE success, or PUT/PATCH when you have nothing to say

Graceful Shutdownโ€‹

Errors don't only happen during request handling. Your server may need to stop for a deployment, a crash, or resource exhaustion. Handling shutdown gracefully prevents data loss and in-flight request failures.

// server.js
const server = app.listen(process.env.PORT || 3000);

// Graceful shutdown on SIGTERM (Kubernetes sends this before killing the pod)
process.on('SIGTERM', () => {
console.log('๐Ÿ›‘ SIGTERM received. Gracefully shutting down...');

// 1. Stop accepting new connections
server.close(() => {
console.log('๐Ÿ”’ No new connections accepted.');
});

// 2. Wait for existing requests to finish (max 30 seconds)
setTimeout(() => {
console.log('โฐ Forcing shutdown after timeout.');
process.exit(1);
}, 30_000);

// 3. Close database connections
// await db.disconnect();

// 4. Close message queue connections
// await queue.close();

// 5. Flush logs / metrics
// await logger.flush();
});

Database Error Handlingโ€‹

Database errors need special treatment โ€” they come from an external system and often carry codes you can interpret.

PostgreSQL Error Codesโ€‹

// utils/dbErrorHandler.js
const { UniqueViolationError, ForeignKeyViolationError } = require('../errors');

function handleDbError(err) {
// PostgreSQL error codes: https://www.postgresql.org/docs/current/errcodes-appendix.html
switch (err.code) {
case '23505': // unique_violation
return new ConflictError('A record with this value already exists', [
{ field: extractField(err.detail), message: err.detail },
]);
case '23503': // foreign_key_violation
return new ValidationError('Referenced resource does not exist', [
{ field: extractField(err.detail), message: err.detail },
]);
case '23502': // not_null_violation
return new ValidationError('A required field is missing', [
{ field: err.column, message: `${err.column} is required` },
]);
default:
// Unknown DB error โ€” wrap as 500
return new AppError('Database error', 500, 'DB_ERROR');
}
}

// Usage in a repository
async function createUser(data) {
try {
return await db('users').insert(data).returning('*');
} catch (err) {
throw handleDbError(err);
}
}

MongoDB / Mongoose Error Handlingโ€‹

function handleMongooseError(err) {
if (err.name === 'ValidationError') {
const details = Object.values(err.errors).map((e) => ({
field: e.path,
message: e.message,
}));
return new ValidationError('Validation failed', details);
}
if (err.code === 11000) {
// Duplicate key
const field = Object.keys(err.keyValue)[0];
return new ConflictError(`A record with this ${field} already exists`, [
{ field, message: `${field} must be unique` },
]);
}
if (err.name === 'CastError') {
return new ValidationError(`Invalid value for ${err.path}`, [
{ field: err.path, message: `Expected ${err.kind}, got "${err.value}"` },
]);
}
return new AppError('Database error', 500, 'DB_ERROR');
}

Third-Party API Error Handlingโ€‹

When your backend calls external services, their failures become your operational errors.

// services/paymentService.js
const axios = require('axios');

class PaymentServiceError extends AppError {
constructor(message, statusCode, upstreamCode) {
super(message, statusCode, 'PAYMENT_SERVICE_ERROR');
this.upstreamCode = upstreamCode;
}
}

async function chargeCustomer(amount, token) {
try {
const { data } = await axios.post(
'https://api.stripe.com/v1/charges',
{ amount, source: token },
{
headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` },
timeout: 5000, // 5 second timeout
},
);
return data;
} catch (err) {
// Network error (DNS, connection refused, timeout)
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') {
throw new ServiceUnavailableError('Payment service is currently unreachable');
}

// Upstream returned an error response
if (err.response) {
const { status, data: upstreamData } = err.response;
const message = upstreamData?.error?.message || 'Payment failed';

if (status === 402) {
throw new PaymentServiceError('Payment declined', 402, 'CARD_DECLINED');
}
if (status === 429) {
throw new TooManyRequestsError('Payment service rate limited', 30);
}
throw new PaymentServiceError(message, 502, 'UPSTREAM_ERROR');
}

// Axios error without response (request was made but no response received)
throw new ServiceUnavailableError('Payment service is not responding');
}
}

Logging Errorsโ€‹

Every error should be logged with enough context to debug it later โ€” but without leaking secrets.

// utils/logger.js
const winston = require('winston');

const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
defaultMeta: { service: 'user-service' },
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'errors.log', level: 'error' }),
],
});

module.exports = logger;

Error Logging in the Error Handlerโ€‹

// middleware/errorHandler.js
const logger = require('../utils/logger');

function errorHandler(err, req, res, next) {
if (res.headersSent) return next(err);

// Build log context
const logContext = {
requestId: req.requestId,
method: req.method,
url: req.originalUrl,
statusCode: err.statusCode || 500,
errorCode: err.code,
errorName: err.name,
stack: err.stack,
// Redact sensitive headers before logging
headers: redactHeaders(req.headers),
};

if (err.isOperational) {
logger.warn('Operational error', logContext);
} else {
logger.error('Programmer error', logContext);
}

// ... rest of error handler
}

function redactHeaders(headers) {
const safe = { ...headers };
delete safe.authorization;
delete safe.cookie;
delete safe['x-api-key'];
return safe;
}

What to Log (and What Not To)โ€‹

โœ… Always LogโŒ Never Log
Error code and messagePasswords, tokens, API keys
Request ID (correlation ID)Full request bodies with PII
HTTP method and URLCredit card numbers
Stack trace (in development)Session tokens
Database query that failedAuthorization header values
Upstream service name and endpointCookies with session data
Timestamp and environmentUnredacted user emails/phones

Testing Error Handlingโ€‹

Test your error paths as thoroughly as your happy paths.

// tests/errors/errorHandler.test.js
const request = require('supertest');
const express = require('express');
const { errorHandler } = require('../../middleware/errorHandler');
const { NotFoundError, ValidationError } = require('../../errors');

describe('Error Handler Middleware', () => {
let app;

beforeEach(() => {
app = express();
app.use(express.json());

// Route that throws different errors
app.get('/test/not-found', (req, res, next) => {
next(new NotFoundError('User not found'));
});

app.get('/test/validation', (req, res, next) => {
next(new ValidationError('Bad input', [{ field: 'email', message: 'Invalid' }]));
});

app.get('/test/crash', (req, res, next) => {
throw new Error('Unexpected crash'); // programmer error
});

app.use(errorHandler);
});

it('should return structured error for NotFoundError', async () => {
const res = await request(app).get('/test/not-found');

expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
expect(res.body.error.code).toBe('NOT_FOUND');
expect(res.body.error.message).toBe('User not found');
expect(res.body.error.timestamp).toBeDefined();
});

it('should include details for ValidationError', async () => {
const res = await request(app).get('/test/validation');

expect(res.status).toBe(422);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
expect(res.body.error.details).toHaveLength(1);
expect(res.body.error.details[0].field).toBe('email');
});

it('should hide stack trace in production for programmer errors', async () => {
process.env.NODE_ENV = 'production';
const res = await request(app).get('/test/crash');

expect(res.status).toBe(500);
expect(res.body.error.code).toBe('INTERNAL_ERROR');
expect(res.body.error.stack).toBeUndefined();
expect(res.body.error.message).toBe('An unexpected error occurred');

process.env.NODE_ENV = 'test'; // restore
});
});

Error Monitoring & Alertingโ€‹

Errors in production need to be visible. Hook into your error handler to send metrics and alerts.

// middleware/errorHandler.js (extended)
const { incrementCounter, recordHistogram } = require('../utils/metrics');

function errorHandler(err, req, res, next) {
// ... error handling logic ...

// Emit metric
incrementCounter('http_errors_total', {
status_code: err.statusCode || 500,
error_code: err.code || 'INTERNAL_ERROR',
route: req.route?.path || req.originalUrl,
});

// Record error latency (time since request started)
if (req.startTime) {
recordHistogram('http_request_duration_seconds', Date.now() - req.startTime, {
status_code: err.statusCode || 500,
route: req.route?.path || req.originalUrl,
});
}

// Alert on sudden spike of 5xx errors
if ((err.statusCode || 500) >= 500 && !err.isOperational) {
// Send to your alerting system (PagerDuty, OpsGenie, Slack webhook)
// alerting.sendCritical(`High severity error: ${err.message}`, { requestId: req.requestId });
}

// ... send response ...
}

Summary: Error Handling Checklistโ€‹

  • Distinguish operational vs programmer errors โ€” handle the first, fix the second
  • Use a custom error class hierarchy โ€” extend a base AppError with statusCode, code, details
  • Return a consistent error envelope โ€” { success: false, error: { code, message, details, requestId, timestamp } }
  • Centralize error handling โ€” Express error middleware (4 params) registered last
  • Wrap async route handlers โ€” use asyncHandler or express-async-errors to catch rejections
  • Attach and propagate correlation IDs โ€” X-Request-Id header on every request
  • Never expose stack traces in production โ€” return a generic message for programmer errors
  • Log every error with context โ€” request ID, method, URL, error code โ€” but redact secrets
  • Handle database and third-party errors explicitly โ€” map vendor-specific codes to your error classes
  • Implement graceful shutdown โ€” close connections, drain in-flight requests, flush logs
  • Test error paths โ€” unit test your error middleware, integration test error responses
  • Monitor error rates โ€” emit metrics, set up alerts for error spikes
  • Choose HTTP status codes consistently โ€” document your code conventions and stick to them

โ† Back to Backend Engineering ยท ยฉ sparshjaswal