Every Express application has two guaranteed sources of failure: requests that don’t match any route, and errors that occur while processing requests that do match. Handling both cleanly — with consistent JSON responses, accurate HTTP status codes, and no stack traces leaking to the client — is a production requirement, not a nice-to-have. This lesson brings together everything from previous lessons to build the complete centralised error handling system for your MERN blog API: the AppError class, the asyncHandler wrapper, the 404 middleware, the global error middleware, and the specific handlers for every Mongoose and JWT error type you will encounter.
The Complete Error Handling Architecture
MERN API Error Handling — Four Components
1. AppError class
└─ Custom Error subclass with statusCode
└─ Thrown anywhere in controllers/middleware
2. asyncHandler wrapper (Express 4 only)
└─ Wraps async route handlers
└─ Catches rejected Promises → passes to next(err)
3. notFound middleware
└─ Registered after all routes
└─ Creates AppError(404) for unmatched requests → next(err)
4. errorHandler middleware (4 arguments)
└─ Registered last of all
└─ Handles ALL errors: AppError, Mongoose, JWT, unexpected
└─ Sends consistent JSON response with correct status code
isOperational: true flag on AppError and should show a descriptive message to the client. Programmer errors should show a generic “Internal server error” message — never expose the real error message in production.console.error(err.stack) in development and a structured logging service (Winston, Pino) in production.Component 1 — AppError Class
// server/src/utils/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.status = statusCode >= 400 && statusCode < 500 ? 'fail' : 'error';
this.isOperational = true; // known, expected error — safe to send message to client
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// Usage examples:
throw new AppError('Post not found', 404);
throw new AppError('Title is required', 400);
throw new AppError('Not authorised to delete this post', 403);
throw new AppError('Email already registered', 409);
Component 2 — asyncHandler Wrapper
// server/src/utils/asyncHandler.js
// Wraps async route handlers to catch rejected promises in Express 4
// Not needed in Express 5 — but safe to use with both versions
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// ── Without asyncHandler ──────────────────────────────────────────────────────
const getPost = async (req, res, next) => {
try {
const post = await Post.findById(req.params.id);
if (!post) return next(new AppError('Not found', 404));
res.json({ data: post });
} catch (err) {
next(err);
}
};
// ── With asyncHandler — identical behaviour, cleaner syntax ───────────────────
const getPost = asyncHandler(async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) throw new AppError('Not found', 404);
res.json({ data: post });
});
Component 3 — notFound Middleware
// server/src/middleware/notFound.js
const AppError = require('../utils/AppError');
const notFound = (req, res, next) => {
next(new AppError(`Route not found: ${req.method} ${req.originalUrl}`, 404));
};
module.exports = notFound;
// Registration in index.js — AFTER all routes:
app.use('/api/posts', postsRouter);
app.use('/api/auth', authRouter);
app.use(notFound); // catches everything that didn't match a route
app.use(errorHandler); // handles all errors including the 404 AppError
Component 4 — Global Error Handler
// server/src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
// Always log the full error on the server
console.error(`[${new Date().toISOString()}] ERROR:`, err.name, err.message);
if (process.env.NODE_ENV === 'development') console.error(err.stack);
// Start with defaults
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal Server Error';
let isOperational = err.isOperational || false;
// ── Mongoose: invalid ObjectId ─────────────────────────────────────────────
if (err.name === 'CastError' && err.kind === 'ObjectId') {
statusCode = 400;
message = `Invalid ID: '${err.value}' is not a valid resource identifier`;
isOperational = true;
}
// ── Mongoose: schema validation failed ─────────────────────────────────────
if (err.name === 'ValidationError') {
statusCode = 400;
message = Object.values(err.errors).map(e => e.message).join('; ');
isOperational = true;
}
// ── MongoDB: duplicate key (e.g. unique email) ─────────────────────────────
if (err.code === 11000) {
const field = Object.keys(err.keyValue || {})[0] || 'field';
statusCode = 409;
message = `${field.charAt(0).toUpperCase() + field.slice(1)} already exists`;
isOperational = true;
}
// ── JWT: invalid token ─────────────────────────────────────────────────────
if (err.name === 'JsonWebTokenError') {
statusCode = 401;
message = 'Invalid token — please log in again';
isOperational = true;
}
// ── JWT: expired token ─────────────────────────────────────────────────────
if (err.name === 'TokenExpiredError') {
statusCode = 401;
message = 'Your session has expired — please log in again';
isOperational = true;
}
// ── Payload too large ─────────────────────────────────────────────────────
if (err.type === 'entity.too.large') {
statusCode = 413;
message = 'Request body too large';
isOperational = true;
}
// In production: hide internal error messages for non-operational errors
if (process.env.NODE_ENV === 'production' && !isOperational) {
message = 'An unexpected error occurred — our team has been notified';
}
res.status(statusCode).json({
success: false,
message,
...(process.env.NODE_ENV === 'development' && {
errorType: err.name,
stack: err.stack,
}),
});
};
module.exports = errorHandler;
End-to-End Error Flow Examples
── Scenario 1: Invalid ObjectId ──────────────────────────────────────────────
GET /api/posts/not-an-id
1. asyncHandler wraps getPostById
2. await Post.findById('not-an-id')
3. Mongoose throws CastError: Cast to ObjectId failed
4. asyncHandler catches → next(castError)
5. errorHandler: name='CastError', kind='ObjectId'
→ statusCode=400, message='Invalid ID: not-an-id...'
Response: 400 { success: false, message: 'Invalid ID: not-an-id...' }
── Scenario 2: Duplicate email on register ───────────────────────────────────
POST /api/auth/register { email: 'existing@email.com' }
1. await User.create({ email: 'existing@email.com', ... })
2. MongoDB throws MongoServerError code 11000 (duplicate key)
3. asyncHandler catches → next(mongoError)
4. errorHandler: err.code === 11000, keyValue = { email: ... }
→ statusCode=409, message='Email already exists'
Response: 409 { success: false, message: 'Email already exists' }
── Scenario 3: Route not found ───────────────────────────────────────────────
GET /api/nonexistent
1. No route matches — falls through all route handlers
2. notFound middleware: next(new AppError('Route not found: GET /api/nonexistent', 404))
3. errorHandler: err.statusCode=404, err.isOperational=true
→ message='Route not found: GET /api/nonexistent'
Response: 404 { success: false, message: 'Route not found: GET /api/nonexistent' }
Common Mistakes
Mistake 1 — Sending different error shapes from different routes
❌ Wrong — inconsistent error response shapes across endpoints:
// Route A: { error: 'Not found' }
// Route B: { message: 'Validation failed', errors: [...] }
// Route C: { status: 'error', msg: 'Unauthorised' }
// React has to handle three different shapes — fragile
✅ Correct — centralise all error responses through one errorHandler that always returns { success: false, message: '...' }.
Mistake 2 — Exposing stack traces in production
❌ Wrong — always sending the stack trace:
res.status(500).json({ message: err.message, stack: err.stack });
// Stack trace may reveal file paths, library versions, and internal logic
✅ Correct — include stack only in development:
res.status(statusCode).json({
success: false,
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
Mistake 3 — Not handling Mongoose validation errors centrally
❌ Wrong — catching and re-formatting Mongoose ValidationError in every controller:
try {
await Post.create(data);
} catch (err) {
if (err.name === 'ValidationError') {
return res.status(400).json({ errors: err.errors }); // duplicated in 10 controllers
}
next(err);
}
✅ Correct — pass all errors to next() and handle Mongoose errors once in the global errorHandler.
Quick Reference
| Error Type | Detection | Status |
|---|---|---|
| AppError (custom) | err.isOperational === true |
err.statusCode |
| Invalid ObjectId | err.name === 'CastError' |
400 |
| Schema validation | err.name === 'ValidationError' |
400 |
| Duplicate key | err.code === 11000 |
409 |
| Invalid JWT | err.name === 'JsonWebTokenError' |
401 |
| Expired JWT | err.name === 'TokenExpiredError' |
401 |
| Body too large | err.type === 'entity.too.large' |
413 |
| Unexpected error | Everything else | 500 |