Error Handling
Production error handling is a system, not an afterthought. Get it right and every bug becomes a clear log entry. Get it wrong and every production incident is a forensic investigation.
Two Categories of Error
Every error in a backend falls into one of two categories. Treating them differently is essential:
Operational Errors
Expected problems with external inputs or state. The app is healthy — this specific operation failed.
- User not found (404)
- Invalid input data (400)
- Email already registered (409)
- Unauthenticated request (401)
- Database record missing (404)
→ Return a helpful error response. Continue running.
Programmer Errors
Bugs in your code. Unexpected state. The app itself is unhealthy.
- Cannot read property of undefined
- Stack overflow
- Incorrect function argument types
- Unhandled edge cases
- Memory leaks
→ Log fully. Consider restarting. Never trust state.
Step 1 — Custom Error Class Hierarchy
// src/errors/AppError.js
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// 400 — malformed request
class BadRequestError extends AppError {
constructor(message = 'Bad request') {
super(message, 400, 'BAD_REQUEST');
}
}
// 400 — with field-level validation details
class ValidationError extends AppError {
constructor(details) {
super('Validation failed', 400, 'VALIDATION_ERROR');
this.details = details; // field-level errors from Zod
}
}
// 401 — not authenticated
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED');
}
}
// 403 — authenticated but not allowed
class ForbiddenError extends AppError {
constructor(message = 'Access denied') {
super(message, 403, 'FORBIDDEN');
}
}
// 404 — resource not found
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
// 409 — conflict (duplicate email, etc)
class ConflictError extends AppError {
constructor(message = 'Conflict') {
super(message, 409, 'CONFLICT');
}
}
// 422 — semantically invalid
class UnprocessableError extends AppError {
constructor(message = 'Unprocessable entity') {
super(message, 422, 'UNPROCESSABLE');
}
}
// 429 — too many requests
class TooManyRequestsError extends AppError {
constructor(retryAfter) {
super('Too many requests', 429, 'RATE_LIMITED');
this.retryAfter = retryAfter;
}
}
module.exports = {
AppError, BadRequestError, ValidationError, UnauthorizedError,
ForbiddenError, NotFoundError, ConflictError, UnprocessableError,
TooManyRequestsError,
};
Step 2 — Throw From Anywhere
const { NotFoundError, ForbiddenError, ConflictError } = require('../errors/AppError');
const createUser = async (req, res) => {
// Check for duplicate email
const existing = await db.user.findUnique({ where: { email: req.body.email } });
if (existing) throw new ConflictError('Email already in use');
const user = await db.user.create({ data: req.body });
res.status(201).json({ data: user });
};
const updateUser = async (req, res) => {
const user = await db.user.findUnique({ where: { id: +req.params.id } });
if (!user) throw new NotFoundError('User');
// Only the owner or admin can update
if (user.id !== req.user.id && req.user.role !== 'admin') {
throw new ForbiddenError('Cannot update another user\'s profile');
}
const updated = await db.user.update({
where: { id: user.id },
data: req.body,
});
res.json({ data: updated });
};
Step 3 — The Central Error Handler
// src/middleware/errorHandler.js
const { AppError } = require('../errors/AppError');
// Translate third-party library errors into our AppError format
const normalise = (err) => {
// Prisma errors — https://www.prisma.io/docs/orm/reference/error-reference
if (err.code === 'P2002') // Unique constraint violation
return new AppError(`${err.meta?.target?.join(', ')} already exists`, 409, 'CONFLICT');
if (err.code === 'P2025') // Record not found
return new AppError('Record not found', 404, 'NOT_FOUND');
if (err.code === 'P2003') // Foreign key constraint
return new AppError('Related record not found', 400, 'BAD_REQUEST');
// JWT errors
if (err.name === 'JsonWebTokenError')
return new AppError('Invalid token', 401, 'UNAUTHORIZED');
if (err.name === 'TokenExpiredError')
return new AppError('Token expired — please log in again', 401, 'TOKEN_EXPIRED');
if (err.name === 'NotBeforeError')
return new AppError('Token not yet valid', 401, 'UNAUTHORIZED');
// express.json() body parse failure
if (err.type === 'entity.parse.failed')
return new AppError('Invalid JSON body', 400, 'INVALID_JSON');
// express.json() body too large
if (err.type === 'entity.too.large')
return new AppError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');
return err; // already an AppError, or unknown — handled below
};
// Production error handler — MUST have 4 parameters
const errorHandler = (err, req, res, next) => {
const error = normalise(err);
// Build structured log payload
const logPayload = {
type: 'error',
requestId: req.id,
method: req.method,
path: req.path,
userId: req.user?.id,
status: error.statusCode ?? 500,
code: error.code,
message: error.message,
stack: error.stack,
};
// Operational errors are expected — log as warning
// Programmer errors are bugs — log as error, consider restarting
if (error.isOperational) {
console.warn(JSON.stringify(logPayload));
} else {
console.error('[CRITICAL]', JSON.stringify(logPayload));
// TODO: alert on-call engineer (PagerDuty, OpsGenie, etc.)
}
// If headers already sent, delegate to Express default handler
if (res.headersSent) {
return next(err);
}
// Operational: reveal the specific error message
if (error.isOperational) {
return res.status(error.statusCode).json({
error: {
code: error.code,
message: error.message,
requestId: req.id,
...(error.details && { details: error.details }),
...(error.retryAfter && { retryAfter: error.retryAfter }),
}
});
}
// Unknown/programmer error: hide internals, expose only requestId
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
requestId: req.id,
}
});
};
module.exports = errorHandler;
Error Flow — The Complete Picture
next(SyntaxError).400 INVALID_JSON. isOperational = true. Returns { error: { code: 'INVALID_JSON', message: 'Invalid JSON body' } }.Handling Unhandled Rejections & Exceptions
// src/index.js — these catch anything that escapes Express entirely
process.on('unhandledRejection', (reason, promise) => {
// An async function threw and the rejection was never caught
console.error('[FATAL] Unhandled rejection:', reason);
// Graceful shutdown — don't accept new requests, finish existing
server.close(() => process.exit(1));
setTimeout(() => process.exit(1), 10_000);
});
process.on('uncaughtException', (err) => {
// A synchronous throw that wasn't caught anywhere
// The process is in an unknown state — you MUST exit
console.error('[FATAL] Uncaught exception:', err);
process.exit(1);
});
Structured Error Logging for Production
// Production apps use structured JSON logs so tools like Datadog,
// CloudWatch, or Elastic can parse and alert on them automatically
// Instead of:
console.error('Error processing payment:', err.message);
// Do this — every field is queryable in your log aggregation tool:
logger.error({
event: 'payment_failed',
requestId: req.id,
userId: req.user?.id,
orderId: req.params.id,
error: {
code: err.code,
message: err.message,
stack: err.stack,
},
});
🧠 Check Your Understanding
Go Deeper
Primary source: Express — Error Handling — read the entire page, especially the section on writing error handlers.
Book: Node.js Design Patterns Ch. 3 — async error handling patterns in depth.
Ask your teacher: "How do I integrate Sentry error monitoring into this error handler?" or "Show me the isOperational pattern from Joyent's production Node.js best practices."