Error handling is where most beginners leave significant quality on the table. An application that crashes on an unhandled Mongoose validation error, or returns a raw stack trace to the client, or loses a Promise rejection silently, is not production-ready. A professional Express application has a complete, consistent error handling strategy: custom error classes that carry status codes, an asyncHandler wrapper that eliminates repetitive try/catch blocks, a single global error middleware that formats every error into the same JSON response shape, and process-level handlers that prevent silent crashes. This lesson builds that complete system.
Error Handling Layers in Express
| Layer | Handles | Mechanism |
|---|---|---|
| Route-level try/catch | Errors in async route handlers | try { } catch(err) { next(err) } |
| asyncHandler wrapper | Eliminates try/catch boilerplate | Wraps async fn, catches rejections, calls next(err) |
| Global error middleware | All errors passed via next(err) |
app.use((err, req, res, next) => {}) |
| 404 middleware | Routes that do not match any handler | app.use((req, res) => res.status(404).json(...)) |
| process.on(‘uncaughtException’) | Synchronous throws outside Express | Process-level handler — should exit gracefully |
| process.on(‘unhandledRejection’) | Promise rejections not caught | Process-level handler — should exit gracefully |
Common Error Types to Handle
| Error | Source | Status Code | Identification |
|---|---|---|---|
| Validation error | Mongoose schema validators | 400 | err.name === 'ValidationError' |
| Duplicate key | MongoDB unique index | 409 | err.code === 11000 |
| Invalid ObjectId | Mongoose CastError (bad _id) | 400 | err.name === 'CastError' |
| JWT expired | jsonwebtoken library | 401 | err.name === 'TokenExpiredError' |
| JWT invalid | jsonwebtoken library | 401 | err.name === 'JsonWebTokenError' |
| Not found | Your application logic | 404 | Custom NotFoundError class |
| Unauthorised | Your application logic | 401 | Custom UnauthorisedError class |
| Forbidden | Your application logic | 403 | Custom ForbiddenError class |
next(err) with a truthy value. It is always registered last in app.js, after all routes. The error object arrives as the first parameter. Any response not sent by a route handler must be sent by the error handler — if the error handler does not send a response, the client hangs.AppError class that extends the native Error. Add a statusCode property and an isOperational flag. Operational errors are expected failures (not found, validation error, unauthorised) — you can safely return their message to the client. Non-operational errors are programmer mistakes (null reference, logic bugs) — return a generic “Server Error” message in production and log the full details internally.process.env.NODE_ENV === 'production' in your error handler and return a generic message for 500 errors. Reserve detailed error information for server-side logs (Morgan, Winston) where only you can see them.Complete Error Handling System
// ── utils/AppError.js — custom error class ───────────────────────────────
class AppError extends Error {
constructor(message, statusCode = 500, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational; // true = expected, false = programmer bug
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError { constructor(msg = 'Resource not found') { super(msg, 404); } }
class UnauthorisedError extends AppError { constructor(msg = 'Authentication required') { super(msg, 401); } }
class ForbiddenError extends AppError { constructor(msg = 'Access denied') { super(msg, 403); } }
class ValidationError extends AppError { constructor(msg = 'Validation failed') { super(msg, 400); } }
class ConflictError extends AppError { constructor(msg = 'Resource already exists') { super(msg, 409); } }
module.exports = { AppError, NotFoundError, UnauthorisedError, ForbiddenError, ValidationError, ConflictError };
// ── utils/asyncHandler.js ────────────────────────────────────────────────
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// ── middleware/errorHandler.js — global error middleware ──────────────────
const { AppError } = require('../utils/AppError');
const errorHandler = (err, req, res, next) => {
let statusCode = err.statusCode || err.status || 500;
let message = err.message || 'Internal Server Error';
let errors = [];
// ── Mongoose validation error ─────────────────────────────────────────
if (err.name === 'ValidationError') {
statusCode = 400;
errors = Object.values(err.errors).map(e => ({
field: e.path,
message: e.message,
}));
message = 'Validation failed';
}
// ── MongoDB duplicate key ─────────────────────────────────────────────
if (err.code === 11000) {
statusCode = 409;
const field = Object.keys(err.keyPattern)[0];
message = `${field} already in use`;
}
// ── Mongoose bad ObjectId ─────────────────────────────────────────────
if (err.name === 'CastError') {
statusCode = 400;
message = `Invalid ${err.path}: '${err.value}' is not a valid ID`;
}
// ── JWT errors ────────────────────────────────────────────────────────
if (err.name === 'TokenExpiredError') { statusCode = 401; message = 'Token expired'; }
if (err.name === 'JsonWebTokenError') { statusCode = 401; message = 'Invalid token'; }
// Log non-operational (programmer) errors
if (!(err instanceof AppError) || !err.isOperational) {
console.error('[UNHANDLED ERROR]', {
message: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
body: req.body,
});
}
const isProd = process.env.NODE_ENV === 'production';
const isServerError = statusCode >= 500;
res.status(statusCode).json({
success: false,
message: isProd && isServerError ? 'Internal Server Error' : message,
...(errors.length && { errors }),
...((!isProd) && { stack: err.stack }),
});
};
module.exports = errorHandler;
// ── middleware/notFound.js ────────────────────────────────────────────────
const { NotFoundError } = require('../utils/AppError');
const notFound = (req, res, next) => {
next(new NotFoundError(`Route not found: ${req.method} ${req.originalUrl}`));
};
module.exports = { notFound };
Using AppError in Controllers
// controllers/task.controller.js
const Task = require('../models/task.model');
const asyncHandler = require('../utils/asyncHandler');
const { NotFoundError, ForbiddenError } = require('../utils/AppError');
// GET /api/tasks/:id
exports.getById = asyncHandler(async (req, res) => {
const task = await Task.findById(req.params.id);
if (!task) {
throw new NotFoundError(`Task ${req.params.id} not found`);
// asyncHandler catches this and calls next(err)
// errorHandler receives it and sends 404 JSON response
}
if (task.user.toString() !== req.user.id) {
throw new ForbiddenError('You do not have access to this task');
}
res.json({ success: true, data: task });
});
// POST /api/tasks
exports.create = asyncHandler(async (req, res) => {
// If Mongoose validation fails, it throws a ValidationError
// asyncHandler catches it and passes to errorHandler
// errorHandler formats it as 400 with individual field errors
const task = await Task.create({ ...req.body, user: req.user.id });
res.status(201).json({ success: true, data: task });
});
// DELETE /api/tasks/:id
exports.remove = asyncHandler(async (req, res) => {
const task = await Task.findOneAndDelete({
_id: req.params.id,
user: req.user.id,
});
if (!task) throw new NotFoundError('Task not found or already deleted');
res.status(204).end();
});
Process-Level Error Handlers
// index.js — add to your entry point
// Handle synchronous throws outside of Express routes
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err);
// Give the server time to finish in-flight requests, then exit
process.exit(1);
});
// Handle Promise rejections not caught with .catch()
process.on('unhandledRejection', (reason, promise) => {
console.error('[unhandledRejection]', reason);
// In Node 15+, unhandled rejections crash the process automatically
// In earlier versions, you need to exit manually
process.exit(1);
});
// Graceful shutdown on SIGTERM (Docker, Kubernetes, Heroku)
process.on('SIGTERM', () => {
console.log('[SIGTERM] Shutting down gracefully');
server.close(() => {
mongoose.connection.close(false, () => {
console.log('MongoDB connection closed');
process.exit(0);
});
});
});
How It Works
Step 1 — Custom Error Classes Carry Status Codes
Extending the built-in Error class lets you create domain-specific error types that carry their own HTTP status code. When a controller throws new NotFoundError(), the error object already has statusCode: 404. The global error handler reads this property directly without needing a complex switch statement to map error messages to status codes.
Step 2 — asyncHandler Eliminates Boilerplate
Without asyncHandler, every async route handler needs a try/catch block that calls next(err). With 20 routes, that is 20 identical try/catch blocks. asyncHandler wraps the function in a Promise, attaches a .catch(next), and returns the wrapped function. Any thrown error or rejected Promise is automatically forwarded to Express’s error pipeline.
Step 3 — The Global Error Handler Is the Single Error Exit Point
All errors — from validation failures to database timeouts to missing records — flow through the single error handler. This guarantees that every error response has the same JSON structure: { success: false, message: '...' }. Angular’s error handling code only needs to read one property to get the error message regardless of which endpoint failed.
Step 4 — Mongoose Errors Are Normalised in the Error Handler
Mongoose throws its own error types with specific name and code properties. The global error handler checks for these and converts them to appropriate HTTP status codes. A Mongoose ValidationError becomes a 400 with field-by-field error details. A duplicate key error (code 11000) becomes a 409 with the conflicting field name.
Step 5 — Process-Level Handlers Prevent Silent Crashes
Even with perfect Express error handling, errors can escape the middleware chain — in startup code, in third-party callbacks that are not promise-based, in event emitters. The uncaughtException and unhandledRejection process handlers are the last line of defence. They log the error (so you can debug it) and exit the process (so a process manager like PM2 can restart it cleanly).
Real-World Example: End-to-End Error Flow
POST /api/auth/register { email: "alice@example.com", password: "123" }
1. authenticate middleware — no token needed for register, skip
2. validateBody middleware — password too short, next(new ValidationError(...))
3. Skips all remaining middleware
4. errorHandler receives: ValidationError { statusCode: 400, message: 'Password too weak' }
5. Response:
HTTP 400
{ "success": false, "message": "Validation failed",
"errors": [{ "field": "password", "message": "Password must be at least 8 characters" }] }
---
DELETE /api/tasks/not-a-valid-id (invalid ObjectId format)
1. authenticate middleware — verifies JWT, sets req.user
2. controller.remove — await Task.findOneAndDelete({ _id: 'not-a-valid-id' })
Mongoose throws CastError { name: 'CastError', path: '_id', value: 'not-a-valid-id' }
3. asyncHandler catches the thrown CastError, calls next(castError)
4. errorHandler receives CastError, maps to 400
5. Response:
HTTP 400
{ "success": false, "message": "Invalid _id: 'not-a-valid-id' is not a valid ID" }
Common Mistakes
Mistake 1 — Not passing error to next() in async handlers
❌ Wrong — error is swallowed, client hangs:
app.get('/tasks/:id', async (req, res) => {
const task = await Task.findById(req.params.id); // CastError thrown
// No try/catch — CastError is an unhandled rejection — client hangs!
res.json(task);
});
✅ Correct — use asyncHandler or explicit try/catch:
app.get('/tasks/:id', asyncHandler(async (req, res) => {
const task = await Task.findById(req.params.id); // CastError caught by asyncHandler
res.json(task);
}));
Mistake 2 — Leaking stack traces to clients in production
❌ Wrong — exposes internal details:
app.use((err, req, res, next) => {
res.status(500).json({ message: err.message, stack: err.stack }); // never do this in prod!
});
✅ Correct — hide details in production:
app.use((err, req, res, next) => {
const isProd = process.env.NODE_ENV === 'production';
res.status(err.status || 500).json({
message: isProd && !err.isOperational ? 'Server Error' : err.message,
...(isProd ? {} : { stack: err.stack }),
});
});
Mistake 3 — Registering the error handler before routes
❌ Wrong — error handler registered before routes never receives their errors:
app.use(errorHandler); // registered first — only catches errors in middleware above it
app.use('/api', routes); // errors here never reach the handler above
✅ Correct — error handler is always the last thing registered:
app.use('/api', routes);
app.use(notFound); // 404 handler
app.use(errorHandler); // LAST — catches all errors from everything above
Quick Reference
| Task | Code |
|---|---|
| Custom error | throw new NotFoundError('Task not found') |
| Forward to error handler | next(new AppError('msg', 400)) |
| Wrap async route | asyncHandler(async (req, res) => { ... }) |
| Error handler signature | (err, req, res, next) — 4 params required |
| Mongoose validation | err.name === 'ValidationError' → 400 |
| Duplicate key | err.code === 11000 → 409 |
| Bad ObjectId | err.name === 'CastError' → 400 |
| 404 middleware | app.use((req, res) => res.status(404).json(...)) |
| Process crash guard | process.on('unhandledRejection', handler) |