Error Handling in Express
Proper error handling is essential for building robust and user-friendly APIs. Express provides a powerful error handling mechanism that allows you to catch, process, and respond to errors gracefully, improving both user experience and application maintainability.
Types of Errors
- Synchronous Errors: Thrown in route handlers and caught automatically
- Asynchronous Errors: Errors in promises that need explicit handling
- Validation Errors: Invalid user input (422 status)
- Authentication Errors: Unauthorized access (401/403 status)
- Not Found Errors: Resource doesn't exist (404 status)
- Server Errors: Unexpected server issues (500 status)
Basic Error Handling
Express automatically catches synchronous errors:
const express = require('express');
const app = express();
// Synchronous error - automatically caught
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) {
// Throwing error - Express catches it
throw new Error('User not found');
}
res.json(user);
});
// Default error handler (must be defined last)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
success: false,
message: 'Something went wrong!'
});
});
app.listen(3000);
Note: Error handling middleware must have four parameters (err, req, res, next) and must be defined after all other middleware and routes.
Handling Asynchronous Errors
Async errors need to be explicitly passed to next():
// Without async/await - use .catch()
app.get('/users/:id', (req, res, next) => {
User.findById(req.params.id)
.then(user => {
if (!user) {
throw new Error('User not found');
}
res.json(user);
})
.catch(next); // Pass error to error handler
});
// With async/await - wrap in try-catch
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
throw new Error('User not found');
}
res.json(user);
} catch (error) {
next(error); // Pass error to error handler
}
});
Custom Error Classes
Create custom error classes for different error types:
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true; // Operational errors we can trust
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// errors/NotFoundError.js
const AppError = require('./AppError');
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404);
}
}
module.exports = NotFoundError;
// errors/ValidationError.js
const AppError = require('./AppError');
class ValidationError extends AppError {
constructor(errors) {
super('Validation failed', 422);
this.errors = errors;
}
}
module.exports = ValidationError;
// errors/UnauthorizedError.js
const AppError = require('./AppError');
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized access') {
super(message, 401);
}
}
module.exports = UnauthorizedError;
Using Custom Error Classes
const NotFoundError = require('./errors/NotFoundError');
const ValidationError = require('./errors/ValidationError');
const UnauthorizedError = require('./errors/UnauthorizedError');
// Route handlers
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
} catch (error) {
next(error);
}
});
app.post('/users', async (req, res, next) => {
try {
// Validation
const errors = validateUser(req.body);
if (errors.length > 0) {
throw new ValidationError(errors);
}
const user = await User.create(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
});
app.get('/admin', checkAuth, (req, res, next) => {
if (!req.user.isAdmin) {
return next(new UnauthorizedError('Admin access required'));
}
res.json({ message: 'Admin panel' });
});
Centralized Error Handler
Create a comprehensive error handling middleware:
// middleware/errorHandler.js
const AppError = require('../errors/AppError');
const errorHandler = (err, req, res, next) => {
// Set default values
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
// Development vs Production error responses
if (process.env.NODE_ENV === 'development') {
sendErrorDev(err, res);
} else {
sendErrorProd(err, res);
}
};
const sendErrorDev = (err, res) => {
// Send detailed error in development
res.status(err.statusCode).json({
success: false,
status: err.status,
message: err.message,
error: err,
stack: err.stack,
errors: err.errors || undefined
});
};
const sendErrorProd = (err, res) => {
// Operational, trusted error: send message to client
if (err.isOperational) {
res.status(err.statusCode).json({
success: false,
status: err.status,
message: err.message,
errors: err.errors || undefined
});
}
// Programming or unknown error: don't leak details
else {
// Log error for debugging
console.error('ERROR:', err);
// Send generic message
res.status(500).json({
success: false,
status: 'error',
message: 'Something went wrong'
});
}
};
module.exports = errorHandler;
// Use in app.js
const errorHandler = require('./middleware/errorHandler');
// ... routes ...
// Error handler (must be last)
app.use(errorHandler);
Best Practice: Never expose sensitive error details (stack traces, database errors) in production. Log them server-side instead.
Async Error Handler Wrapper
Create a wrapper to avoid try-catch blocks in every async route:
// utils/asyncHandler.js
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
module.exports = asyncHandler;
// Usage - no more try-catch needed!
const asyncHandler = require('./utils/asyncHandler');
const NotFoundError = require('./errors/NotFoundError');
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
}));
app.post('/users', asyncHandler(async (req, res) => {
const user = await User.create(req.body);
res.status(201).json(user);
}));
// Errors are automatically caught and passed to error handler!
Handling Specific Error Types
Handle different error types appropriately:
const errorHandler = (err, req, res, next) => {
// MongoDB CastError (invalid ID format)
if (err.name === 'CastError') {
err = new AppError(`Invalid ${err.path}: ${err.value}`, 400);
}
// MongoDB Duplicate Key Error
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
err = new AppError(`Duplicate value for ${field}`, 409);
}
// MongoDB Validation Error
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(e => e.message);
err = new ValidationError(errors);
}
// JWT Errors
if (err.name === 'JsonWebTokenError') {
err = new UnauthorizedError('Invalid token');
}
if (err.name === 'TokenExpiredError') {
err = new UnauthorizedError('Token expired');
}
// Multer file upload errors
if (err.name === 'MulterError') {
if (err.code === 'LIMIT_FILE_SIZE') {
err = new AppError('File size too large', 413);
}
}
// Send error response
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
if (process.env.NODE_ENV === 'development') {
sendErrorDev(err, res);
} else {
sendErrorProd(err, res);
}
};
404 Not Found Handler
Handle routes that don't exist:
const NotFoundError = require('./errors/NotFoundError');
// ... all routes ...
// 404 handler - must be after all routes
app.all('*', (req, res, next) => {
next(new NotFoundError(`Route ${req.originalUrl} not found`));
});
// Error handler - must be last
app.use(errorHandler);
Error Logging
Log errors for debugging and monitoring:
const winston = require('winston');
// Configure logger
const logger = winston.createLogger({
level: 'error',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// Add console transport in development
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
// Error handler with logging
const errorHandler = (err, req, res, next) => {
// Log error
logger.error({
message: err.message,
stack: err.stack,
statusCode: err.statusCode,
timestamp: new Date().toISOString(),
method: req.method,
url: req.originalUrl,
ip: req.ip,
userId: req.user?.id
});
// Send response
err.statusCode = err.statusCode || 500;
if (process.env.NODE_ENV === 'development') {
sendErrorDev(err, res);
} else {
sendErrorProd(err, res);
}
};
Warning: Never log sensitive information like passwords, tokens, or personal data in error logs. Sanitize data before logging.
Handling Unhandled Rejections
Catch unhandled promise rejections globally:
// app.js
const app = require('./app');
const server = app.listen(3000, () => {
console.log('Server running on port 3000');
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION! Shutting down...');
console.error(err.name, err.message);
// Close server gracefully
server.close(() => {
process.exit(1);
});
});
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('UNCAUGHT EXCEPTION! Shutting down...');
console.error(err.name, err.message);
process.exit(1);
});
Error Response Formatting
Consistent error response format:
// Production error response format
{
"success": false,
"status": "fail",
"message": "User not found",
"errors": [
{
"field": "email",
"message": "Invalid email format"
}
]
}
// Development error response format (includes debugging info)
{
"success": false,
"status": "error",
"message": "Database connection failed",
"error": {
"name": "MongoNetworkError",
"message": "Connection timeout"
},
"stack": "Error: Connection timeout\n at ..."
}
Exercise: Implement comprehensive error handling for a products API:
- Create custom error classes: NotFoundError, ValidationError, UnauthorizedError, ConflictError
- Implement asyncHandler wrapper to eliminate try-catch blocks
- Create centralized error handler middleware with dev/prod modes
- Handle MongoDB errors (CastError, duplicate key, validation)
- Implement 404 handler for non-existent routes
- Add error logging with Winston
- Test error handling by triggering various error scenarios
API Error Response Standards
Follow industry standards for API error responses:
- HTTP Status Codes: Use appropriate status codes (400, 401, 403, 404, 422, 500)
- Error Messages: Clear, actionable messages for users
- Error Codes: Optional custom error codes for client-side handling
- Field-Level Errors: For validation, specify which fields have errors
- Consistent Format: Same structure across all endpoints
- No Sensitive Data: Never expose passwords, tokens, or internal details
- Documentation: Document all possible error responses
// Example comprehensive error response
{
"success": false,
"status": "fail",
"message": "Validation failed",
"errorCode": "VALIDATION_ERROR",
"errors": [
{
"field": "email",
"message": "Invalid email format",
"code": "INVALID_EMAIL"
},
{
"field": "password",
"message": "Password must be at least 8 characters",
"code": "PASSWORD_TOO_SHORT"
}
],
"timestamp": "2024-01-15T10:30:00.000Z",
"path": "/api/users"
}
Error Handling Best Practices
- Always use error handling middleware, don't handle errors in routes
- Create custom error classes for different error types
- Use asyncHandler wrapper to simplify async error handling
- Distinguish between operational and programming errors
- Never expose sensitive information in error messages
- Log all errors with context (timestamp, user, request details)
- Return different error details in development vs production
- Use appropriate HTTP status codes consistently
- Provide actionable error messages that help users fix issues
- Handle unhandled rejections and uncaught exceptions
- Implement graceful shutdown on critical errors
- Document all possible error responses in API documentation
In the next lesson, we'll learn how to implement authentication using JSON Web Tokens (JWT) to secure our API endpoints and manage user sessions.