Episode 3 — NodeJS MongoDB Backend Architecture / 3.17 — Error Handling in Express
3.17.b — Centralized Error Handling
Instead of scattering error-response logic across every route handler, a centralized error handler catches all errors in one place, formats them consistently, and handles Mongoose, JWT, and custom errors with appropriate status codes and messages.
<< Previous: 3.17.a — Error Handling Basics | Next: 3.17.c — Async Handler Utility >>
1. Error-Handling Middleware: The Four-Parameter Signature
Express identifies error-handling middleware by its four parameters. This is not optional -- if you omit one parameter, Express treats it as a regular middleware and will never call it with errors.
// CORRECT: Error-handling middleware (4 parameters)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ message: 'Something went wrong' });
});
// WRONG: Only 3 parameters — Express treats this as regular middleware
app.use((err, req, res) => {
// This will NEVER be called as an error handler!
// Express sees 3 params and treats it as (req, res, next)
});
Must Be Registered AFTER All Routes
Error-handling middleware must come after all your routes and regular middleware. Express processes middleware in order, and errors need to "fall through" to reach the error handler.
const express = require('express');
const app = express();
// 1. Body parser middleware
app.use(express.json());
// 2. Regular middleware
app.use(authMiddleware);
// 3. All your routes
app.use('/api/users', userRoutes);
app.use('/api/products', productRoutes);
app.use('/api/orders', orderRoutes);
// 4. 404 handler (for unmatched routes)
app.all('*', (req, res, next) => {
next(new ApiError(404, `Route ${req.originalUrl} not found`));
});
// 5. Error handler — MUST be LAST
app.use(errorHandler);
MIDDLEWARE ORDER
express.json() ← Parse body
authMiddleware ← Check auth
/api/users routes ← Handle requests
/api/products routes
404 handler ← Catch unmatched routes
errorHandler ← Catch ALL errors (must be last)
2. Creating the ApiError Custom Error Class
JavaScript's built-in Error class only has message and stack. For HTTP APIs, you need more information: the HTTP status code, whether the error is operational (expected), and possibly additional details.
// utils/ApiError.js
class ApiError extends Error {
/**
* Create an API error.
* @param {number} statusCode - HTTP status code (400, 401, 404, 500, etc.)
* @param {string} message - Human-readable error message
* @param {boolean} isOperational - True if expected/operational, false if bug
* @param {string} stack - Optional custom stack trace
*/
constructor(statusCode, message, isOperational = true, stack = '') {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
module.exports = ApiError;
Using ApiError
const ApiError = require('../utils/ApiError');
// In a route handler or service layer:
if (!user) {
throw new ApiError(404, 'User not found');
}
if (!req.body.email) {
throw new ApiError(400, 'Email is required');
}
if (user.role !== 'admin') {
throw new ApiError(403, 'Only admins can access this resource');
}
// For unexpected/programmer errors:
throw new ApiError(500, 'Database connection failed', false);
// isOperational = false → this is a bug, not an expected condition
status vs statusCode
| Property | Value | Meaning |
|---|---|---|
statusCode | 404 | The numeric HTTP status code |
status | 'fail' | 4xx errors = 'fail' (client error) |
status | 'error' | 5xx errors = 'error' (server error) |
isOperational | true | Expected error (bad input, not found) |
isOperational | false | Programmer error (bug, crash) |
3. The Centralized Error Handler Function
The error handler inspects the error object, logs it, and sends a formatted response. It behaves differently in development and production.
// middleware/errorHandler.js
const ApiError = require('../utils/ApiError');
const errorHandler = (err, req, res, next) => {
// Default values
err.statusCode = err.statusCode || 500;
err.message = err.message || 'Internal Server Error';
// Log the error
if (err.statusCode >= 500) {
console.error('ERROR:', {
message: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
ip: req.ip,
timestamp: new Date().toISOString(),
});
}
// Development: send full error details including stack trace
if (process.env.NODE_ENV === 'development') {
return res.status(err.statusCode).json({
success: false,
status: err.status || 'error',
message: err.message,
stack: err.stack,
error: err,
});
}
// Production: send clean error message, no stack trace
if (err.isOperational) {
// Operational errors: safe to send the message to the client
return res.status(err.statusCode).json({
success: false,
status: err.status || 'error',
message: err.message,
});
}
// Programmer errors in production: do not leak details
return res.status(500).json({
success: false,
status: 'error',
message: 'Something went wrong',
});
};
module.exports = errorHandler;
Development vs Production Responses
DEVELOPMENT RESPONSE (full details for debugging):
{
"success": false,
"status": "fail",
"message": "User not found",
"stack": "ApiError: User not found\n at /app/controllers/user.js:25:11\n...",
"error": { "statusCode": 404, "isOperational": true }
}
PRODUCTION RESPONSE — OPERATIONAL ERROR (safe message):
{
"success": false,
"status": "fail",
"message": "User not found"
}
PRODUCTION RESPONSE — PROGRAMMER ERROR (no details leaked):
{
"success": false,
"status": "error",
"message": "Something went wrong"
}
4. Handling Specific Error Types
Different libraries throw different error types. Your centralized handler should convert these into consistent ApiError instances.
Mongoose CastError
Thrown when an invalid value is used where a MongoDB ObjectId is expected.
// Example trigger: GET /api/users/not-a-valid-id
// Mongoose throws: CastError: Cast to ObjectId failed for value "not-a-valid-id"
const handleCastError = (err) => {
const message = `Invalid ${err.path}: ${err.value}`;
return new ApiError(400, message);
};
Mongoose ValidationError
Thrown when a document fails schema validation. Contains nested errors for each invalid field.
// Example trigger: POST /api/users with { name: '' } when name is required
// Mongoose throws: ValidationError with errors.name, errors.email, etc.
const handleValidationError = (err) => {
const errors = Object.values(err.errors).map((e) => e.message);
const message = `Validation failed: ${errors.join('. ')}`;
return new ApiError(400, message);
};
MongoDB Duplicate Key Error (code 11000)
Thrown when a unique index constraint is violated (e.g., duplicate email).
// Example trigger: POST /api/users with an email that already exists
// MongoDB throws: MongoServerError with code 11000
const handleDuplicateKeyError = (err) => {
const field = Object.keys(err.keyValue)[0];
const value = err.keyValue[field];
const message = `Duplicate value '${value}' for field '${field}'. Please use another value.`;
return new ApiError(409, message);
};
JWT Errors
// JsonWebTokenError: malformed token, invalid signature
const handleJWTError = () =>
new ApiError(401, 'Invalid token. Please log in again.');
// TokenExpiredError: token past its expiration date
const handleJWTExpiredError = () =>
new ApiError(401, 'Your token has expired. Please log in again.');
Error Type Summary
| Error Type | Source | Status Code | User Message |
|---|---|---|---|
CastError | Mongoose | 400 | "Invalid {field}: {value}" |
ValidationError | Mongoose | 400 | "Validation failed: field messages" |
Code 11000 | MongoDB | 409 | "Duplicate value for field" |
JsonWebTokenError | jsonwebtoken | 401 | "Invalid token" |
TokenExpiredError | jsonwebtoken | 401 | "Token expired" |
SyntaxError (body parser) | Express | 400 | "Invalid JSON in request body" |
5. Enhanced Error Handler with Type Detection
Combine all the specific error converters into one comprehensive error handler:
// middleware/errorHandler.js
const ApiError = require('../utils/ApiError');
const handleCastError = (err) => {
const message = `Invalid ${err.path}: ${err.value}`;
return new ApiError(400, message);
};
const handleValidationError = (err) => {
const errors = Object.values(err.errors).map((e) => e.message);
const message = `Validation failed: ${errors.join('. ')}`;
return new ApiError(400, message);
};
const handleDuplicateKeyError = (err) => {
const field = Object.keys(err.keyValue)[0];
const value = err.keyValue[field];
const message = `Duplicate value '${value}' for field '${field}'. Please use another value.`;
return new ApiError(409, message);
};
const handleJWTError = () =>
new ApiError(401, 'Invalid token. Please log in again.');
const handleJWTExpiredError = () =>
new ApiError(401, 'Your token has expired. Please log in again.');
const handleSyntaxError = () =>
new ApiError(400, 'Invalid JSON in request body');
const errorHandler = (err, req, res, next) => {
// Clone the error to avoid mutating the original
let error = { ...err, message: err.message, stack: err.stack };
// Convert known error types to ApiError
if (err.name === 'CastError') error = handleCastError(err);
if (err.name === 'ValidationError') error = handleValidationError(err);
if (err.code === 11000) error = handleDuplicateKeyError(err);
if (err.name === 'JsonWebTokenError') error = handleJWTError();
if (err.name === 'TokenExpiredError') error = handleJWTExpiredError();
if (err.name === 'SyntaxError' && err.status === 400) error = handleSyntaxError();
// Set defaults
error.statusCode = error.statusCode || 500;
error.status = error.status || 'error';
// Log server errors
if (error.statusCode >= 500) {
console.error('SERVER ERROR:', {
message: error.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
body: req.body,
timestamp: new Date().toISOString(),
});
}
// Development response: full details
if (process.env.NODE_ENV === 'development') {
return res.status(error.statusCode).json({
success: false,
status: error.status,
message: error.message,
stack: err.stack,
error: err,
});
}
// Production response: operational errors show message, others are generic
if (error.isOperational) {
return res.status(error.statusCode).json({
success: false,
status: error.status,
message: error.message,
});
}
return res.status(500).json({
success: false,
status: 'error',
message: 'Something went wrong',
});
};
module.exports = errorHandler;
6. 404 Not Found Handler for Unknown Routes
Any request that does not match a defined route should receive a 404 response. Place this after all route definitions but before the error handler.
// Place after all routes, before errorHandler
app.all('*', (req, res, next) => {
next(new ApiError(404, `Cannot find ${req.originalUrl} on this server`));
});
Why app.all('*') Instead of app.use()?
| Approach | Behavior |
|---|---|
app.all('*', handler) | Matches ALL HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.) for any path |
app.use(handler) | Also works, but app.all('*') is more explicit about intent |
// Alternative: using app.use
app.use((req, res, next) => {
next(new ApiError(404, `Cannot find ${req.originalUrl} on this server`));
});
// Both work. app.all('*') is the more common convention.
7. Complete Working Example
Here is a full Express application with all error handling pieces assembled:
// ----- utils/ApiError.js -----
class ApiError extends Error {
constructor(statusCode, message, isOperational = true, stack = '') {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
module.exports = ApiError;
// ----- middleware/errorHandler.js -----
const ApiError = require('../utils/ApiError');
const handleCastError = (err) =>
new ApiError(400, `Invalid ${err.path}: ${err.value}`);
const handleValidationError = (err) => {
const messages = Object.values(err.errors).map((e) => e.message);
return new ApiError(400, `Validation failed: ${messages.join('. ')}`);
};
const handleDuplicateKeyError = (err) => {
const field = Object.keys(err.keyValue)[0];
return new ApiError(409, `Duplicate value for '${field}'. Please use another value.`);
};
const handleJWTError = () =>
new ApiError(401, 'Invalid token. Please log in again.');
const handleJWTExpiredError = () =>
new ApiError(401, 'Your token has expired. Please log in again.');
const errorHandler = (err, req, res, next) => {
let error = { ...err, message: err.message, stack: err.stack };
if (err.name === 'CastError') error = handleCastError(err);
if (err.name === 'ValidationError') error = handleValidationError(err);
if (err.code === 11000) error = handleDuplicateKeyError(err);
if (err.name === 'JsonWebTokenError') error = handleJWTError();
if (err.name === 'TokenExpiredError') error = handleJWTExpiredError();
error.statusCode = error.statusCode || 500;
if (error.statusCode >= 500) {
console.error('ERROR:', err.stack);
}
if (process.env.NODE_ENV === 'development') {
return res.status(error.statusCode).json({
success: false,
message: error.message,
stack: err.stack,
error: err,
});
}
if (error.isOperational) {
return res.status(error.statusCode).json({
success: false,
message: error.message,
});
}
return res.status(500).json({
success: false,
message: 'Something went wrong',
});
};
module.exports = errorHandler;
// ----- app.js -----
const express = require('express');
const mongoose = require('mongoose');
const ApiError = require('./utils/ApiError');
const errorHandler = require('./middleware/errorHandler');
const app = express();
// Body parser
app.use(express.json({ limit: '10kb' }));
// Routes
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await mongoose.model('User').findById(req.params.id);
if (!user) {
return next(new ApiError(404, 'User not found'));
}
res.json({ success: true, data: user });
} catch (err) {
next(err);
}
});
app.post('/api/users', async (req, res, next) => {
try {
const user = await mongoose.model('User').create(req.body);
res.status(201).json({ success: true, data: user });
} catch (err) {
next(err); // Mongoose ValidationError or duplicate key → handled by errorHandler
}
});
// 404 handler for undefined routes
app.all('*', (req, res, next) => {
next(new ApiError(404, `Cannot find ${req.originalUrl} on this server`));
});
// Centralized error handler (MUST be last middleware)
app.use(errorHandler);
module.exports = app;
// ----- server.js -----
process.on('uncaughtException', (err) => {
console.error('UNCAUGHT EXCEPTION! Shutting down...');
console.error(err.name, err.message);
process.exit(1);
});
const app = require('./app');
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});
process.on('unhandledRejection', (reason) => {
console.error('UNHANDLED REJECTION! Shutting down...');
console.error(reason);
server.close(() => process.exit(1));
});
Testing the Error Handler
# Health check (200)
curl http://localhost:3000/api/health
# Valid user (200) or not found (404)
curl http://localhost:3000/api/users/507f1f77bcf86cd799439011
# Invalid ID format — triggers CastError (400)
curl http://localhost:3000/api/users/invalid-id
# Undefined route — triggers 404 handler
curl http://localhost:3000/api/nonexistent
# Missing required fields — triggers ValidationError (400)
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{}'
# Duplicate key — triggers 11000 error (409)
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"email": "existing@email.com"}'
# Malformed JSON — triggers SyntaxError (400)
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{invalid json}'
8. Error Handler Design Decisions
Should You Use res.status().json() or res.status().send()?
| Method | Use Case |
|---|---|
res.json() | API responses — always use for REST APIs |
res.send() | HTML responses or generic content |
res.render() | Server-rendered HTML with a template engine |
For REST APIs, always use res.json() so clients can reliably parse the response.
Should the Error Handler Call next()?
// Usually NO — the error handler is the final stop
app.use((err, req, res, next) => {
res.status(err.statusCode || 500).json({ message: err.message });
// Do NOT call next() — this is the end of the chain
});
// Exception: if you have MULTIPLE error handlers in sequence
app.use(logErrorHandler); // Log the error, then next(err)
app.use(sendErrorResponse); // Send the response
Multiple Error Handlers
You can chain error-handling middleware. Each receives the error, processes it, and either responds or passes it along with next(err).
// Error logger — logs and passes the error along
const logErrors = (err, req, res, next) => {
console.error(err.stack);
next(err); // Pass to the next error handler
};
// Error responder — sends the HTTP response
const sendErrorResponse = (err, req, res, next) => {
res.status(err.statusCode || 500).json({
success: false,
message: err.message,
});
};
app.use(logErrors);
app.use(sendErrorResponse);
Key Takeaways
- Error-handling middleware must have exactly 4 parameters:
(err, req, res, next)-- this is how Express identifies it - The error handler must be registered after all routes using
app.use(errorHandler) - The
ApiErrorclass extendsErrorand addsstatusCode,isOperational, andstatusfor clean API responses - Development responses include the full stack trace; production responses hide internal details
- Handle specific error types: Mongoose
CastError(400),ValidationError(400), duplicate key11000(409), JWT errors (401) - Use
app.all('*')before the error handler to catch requests to undefined routes (404) - The
isOperationalflag distinguishes expected errors (safe to show message) from bugs (show generic message in production)
Explain-It Challenge
Scenario: Your API has the following user registration flow: the user sends POST /api/register with { name, email, password }. List every possible error that could occur during this request (think about: JSON parsing, validation, database operations, hashing). For each error, specify which error type it would be (Mongoose ValidationError, CastError, duplicate key, etc.), what status code you would return, and what message the user should see. Then show how each would be handled by the centralized error handler without any extra code in the route handler itself.
<< Previous: 3.17.a — Error Handling Basics | Next: 3.17.c — Async Handler Utility >>