Introduction to Error Handling in Express
Error handling is a critical aspect of building reliable Express.js applications. A well-designed error handling system helps identify and resolve issues quickly, improves user experience, and makes your application more robust.
"A system is only as robust as its error handling. Everything else is just an optimistic assumption that things will work as expected."
Why Error Handling Matters
- User Experience: Prevent users from seeing cryptic error messages
- Security: Avoid leaking sensitive information in error responses
- Debugging: Easily identify and fix issues in production
- Code Maintainability: Centralize error handling logic
- Stability: Prevent application crashes from unhandled errors
Types of Errors in Express Applications
To handle errors effectively, it's important to understand the different types of errors that can occur in an Express application.
Synchronous Errors
These are errors that occur during normal synchronous code execution:
app.get('/example', (req, res) => {
// This will throw a synchronous error
const data = JSON.parse('{"invalid json"}');
res.json(data);
});
Asynchronous Errors
These errors occur in asynchronous operations and require additional handling:
app.get('/async-example', (req, res) => {
// Asynchronous error - not automatically caught by Express
fs.readFile('non-existent-file.txt', (err, data) => {
if (err) {
// We need to handle this manually
console.error(err);
return res.status(500).json({ error: 'File read error' });
}
res.send(data);
});
});
Promise Rejections
In modern Express applications using async/await, unhandled promise rejections can cause issues:
app.get('/promise-example', async (req, res) => {
// Unhandled promise rejection without try/catch
const data = await fetchDataFromDatabase();
res.json(data);
});
Operational vs. Programming Errors
- Operational Errors: Expected errors that occur during normal operation (e.g., invalid user input, network failures)
- Programming Errors: Bugs or unexpected conditions in code (e.g., TypeError, reference errors, syntax errors)
Real-World Error Examples
| Error Type | Example | Handling Approach |
|---|---|---|
| Validation Error | User submits invalid data | Return 400 Bad Request with details |
| Authentication Error | Invalid or expired token | Return 401 Unauthorized |
| Database Error | Connection timeout | Log error, return 503 Service Unavailable |
| Not Found Error | Resource doesn't exist | Return 404 Not Found |
| Programming Error | Trying to access property of undefined | Log error, return generic 500 error in production |
Default Error Handling in Express
Express comes with a built-in error handler that handles errors occurring in the application. However, this default handler is quite basic and not suitable for production applications.
How Default Error Handling Works
When an error occurs in synchronous code, Express will catch it automatically and pass it to its error handling middleware:
app.get('/example', (req, res) => {
throw new Error('Something went wrong!');
// Express catches this error automatically
});
Without a custom error handler, Express will:
- Log the error stack trace to the console
- Send a 500 Internal Server Error response with basic HTML
- Include the error message and stack trace in development mode
Default Error Response
The default error response looks something like this:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: Something went wrong!<br>
at /app/routes/example.js:5:9
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
...</pre>
</body>
</html>
This is problematic for several reasons:
- It exposes sensitive stack trace information in production
- It returns HTML instead of JSON for API routes
- It doesn't provide a way to handle different types of errors differently
Custom Error Handling Middleware
Custom error handling middleware in Express allows you to catch, process, and respond to errors in a consistent way across your application.
Basic Error Middleware Structure
Error-handling middleware requires four arguments (err, req, res, next):
app.use((err, req, res, next) => {
// Error handling logic here
console.error(err.stack);
res.status(500).json({
error: {
message: 'Something went wrong!'
}
});
});
Middleware Placement
Error-handling middleware should be defined after all other app.use() and route calls:
const express = require('express');
const app = express();
// Regular middleware
app.use(express.json());
// Routes
app.get('/', (req, res) => {
res.send('Hello World');
});
app.get('/error', (req, res) => {
throw new Error('Test error');
});
// Error handling middleware (must be last!)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: {
message: 'An unexpected error occurred'
}
});
});
Custom Error Response Format
A good error response should include:
- Appropriate HTTP status code
- Error message suitable for the client
- Error code or type for programmatic handling
- Request identifier for troubleshooting (optional)
app.use((err, req, res, next) => {
// Set default values
const statusCode = err.statusCode || 500;
const errorCode = err.code || 'INTERNAL_ERROR';
const message = err.message || 'An unexpected error occurred';
// Generate request ID
const requestId = req.id || generateRequestId();
// Log error for server-side troubleshooting
console.error(`[${requestId}] ${err.stack}`);
// Send response to client
res.status(statusCode).json({
error: {
code: errorCode,
message: message,
requestId: requestId
}
});
});
Creating a Custom Error Class
Creating custom error classes helps standardize error handling throughout your application.
Basic Custom Error
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // Flag to identify operational errors
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
Specialized Error Types
You can create specialized error types for different scenarios:
// errors/index.js
const AppError = require('./AppError');
class NotFoundError extends AppError {
constructor(resource = 'Resource', code = 'NOT_FOUND') {
super(`${resource} not found`, 404, code);
}
}
class ValidationError extends AppError {
constructor(message = 'Validation error', errors = null, code = 'VALIDATION_ERROR') {
super(message, 400, code);
this.errors = errors;
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication failed', code = 'AUTHENTICATION_ERROR') {
super(message, 401, code);
}
}
class AuthorizationError extends AppError {
constructor(message = 'Not authorized', code = 'AUTHORIZATION_ERROR') {
super(message, 403, code);
}
}
class DatabaseError extends AppError {
constructor(message = 'Database error', code = 'DATABASE_ERROR') {
super(message, 500, code);
}
}
module.exports = {
AppError,
NotFoundError,
ValidationError,
AuthenticationError,
AuthorizationError,
DatabaseError
};
Using Custom Errors in Routes
const { NotFoundError, ValidationError } = require('../errors');
app.get('/users/:id', (req, res, next) => {
const user = findUser(req.params.id);
if (!user) {
// Use our custom error
return next(new NotFoundError('User'));
}
res.json(user);
});
app.post('/users', (req, res, next) => {
const { username, email, password } = req.body;
// Validation example
const errors = {};
if (!username) {
errors.username = 'Username is required';
}
if (!email || !isValidEmail(email)) {
errors.email = 'Valid email is required';
}
if (Object.keys(errors).length > 0) {
return next(new ValidationError('Validation failed', errors));
}
// Continue with valid data
// ...
});
Real-World Example: E-commerce API Errors
In an e-commerce application, you might have specialized errors like:
class ProductOutOfStockError extends AppError {
constructor(productId) {
super(
`Product ${productId} is out of stock`,
400,
'PRODUCT_OUT_OF_STOCK'
);
this.productId = productId;
}
}
class PaymentFailedError extends AppError {
constructor(reason, paymentId) {
super(
`Payment failed: ${reason}`,
400,
'PAYMENT_FAILED'
);
this.paymentId = paymentId;
this.reason = reason;
}
}
// Usage in order creation
app.post('/orders', async (req, res, next) => {
try {
const { items, paymentDetails } = req.body;
// Check stock
for (const item of items) {
const inStock = await checkStock(item.productId, item.quantity);
if (!inStock) {
throw new ProductOutOfStockError(item.productId);
}
}
// Process payment
const paymentResult = await processPayment(paymentDetails);
if (!paymentResult.success) {
throw new PaymentFailedError(
paymentResult.reason,
paymentResult.id
);
}
// Create order if everything is successful
const order = await createOrder(items, paymentResult.id);
res.status(201).json(order);
} catch (error) {
next(error); // Pass to error handler
}
});
Handling Different Error Types
A comprehensive error handler should process different types of errors appropriately.
Complete Error Middleware Example
// middleware/errorHandler.js
const { AppError } = require('../errors');
// Determine if we're in production
const isProduction = process.env.NODE_ENV === 'production';
const errorHandler = (err, req, res, next) => {
console.error(err);
let error = { ...err };
error.message = err.message;
// Generate request ID for tracking
const requestId = req.id || Math.random().toString(36).substring(2, 15);
// Handle mongoose duplicate key error
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
const value = err.keyValue[field];
const message = `Duplicate field value: ${field}. Value '${value}' already exists.`;
error = new AppError(message, 409, 'DUPLICATE_FIELD');
}
// Handle mongoose validation error
if (err.name === 'ValidationError') {
const errors = {};
Object.keys(err.errors).forEach(field => {
errors[field] = err.errors[field].message;
});
error = new AppError('Validation failed', 400, 'VALIDATION_ERROR');
error.errors = errors;
}
// Handle mongoose cast error (e.g., invalid ObjectId)
if (err.name === 'CastError') {
const message = `Invalid ${err.path}: ${err.value}`;
error = new AppError(message, 400, 'INVALID_FIELD');
}
// Handle JWT errors
if (err.name === 'JsonWebTokenError') {
error = new AppError('Invalid token', 401, 'INVALID_TOKEN');
}
if (err.name === 'TokenExpiredError') {
error = new AppError('Token expired', 401, 'EXPIRED_TOKEN');
}
// Determine if this is an operational error (expected error)
const isOperationalError = error.isOperational || err.isOperational;
// Response structure
const response = {
success: false,
error: {
message: error.message || 'Something went wrong',
code: error.code || 'INTERNAL_ERROR',
requestId
}
};
// Add detailed errors if available and not in production
if (error.errors && (!isProduction || isOperationalError)) {
response.error.details = error.errors;
}
// Only include stack trace in development and for non-operational errors
if (!isProduction && !isOperationalError) {
response.error.stack = err.stack;
}
// Log error for monitoring and debugging
const logData = {
requestId,
url: req.originalUrl,
method: req.method,
body: req.body,
error: {
message: err.message,
name: err.name,
code: error.code,
stack: err.stack
}
};
// Use structured logging in production
if (isProduction) {
console.error(JSON.stringify(logData));
} else {
console.error('Error details:', logData);
}
// Return response to client
res.status(error.statusCode || 500).json(response);
};
module.exports = errorHandler;
Handling Specific Status Codes
For common errors, you can create specific handlers:
// Handle 404 errors (route not found)
app.use((req, res, next) => {
const error = new AppError(`Route ${req.originalUrl} not found`, 404, 'ROUTE_NOT_FOUND');
next(error);
});
// Handle errors
app.use(errorHandler);
Handling Asynchronous Errors
Asynchronous errors are particularly challenging to handle in Express, as they can escape your error middleware if not properly managed.
The Problem with Asynchronous Errors
// This error won't be caught by Express error handlers
app.get('/users/:id', async (req, res) => {
// If this throws, Express won't catch it
const user = await User.findById(req.params.id);
if (!user) {
// This will be caught
throw new Error('User not found');
}
res.json(user);
});
Solution 1: Try/Catch in Each Route
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return next(new NotFoundError('User'));
}
res.json(user);
} catch (error) {
next(error); // Pass to error middleware
}
});
Solution 2: Wrapper Function
To avoid repeating try/catch blocks, create a utility function:
// utils/catchAsync.js
const catchAsync = fn => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
// Usage
app.get('/users/:id', catchAsync(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) {
return next(new NotFoundError('User'));
}
res.json(user);
}));
Solution 3: Express Router Wrapper
You can wrap all route handlers in a router:
// utils/asyncRouter.js
const express = require('express');
function asyncRouter() {
const router = express.Router();
const originalMethods = ['get', 'post', 'put', 'delete', 'patch'];
// Override each method to wrap handlers in try/catch
originalMethods.forEach(method => {
const originalMethod = router[method];
router[method] = function(path, ...handlers) {
const wrappedHandlers = handlers.map(handler => {
if (handler.length === 4) { // Error middleware (4 args)
return handler;
}
return async (req, res, next) => {
try {
await handler(req, res, next);
} catch (error) {
next(error);
}
};
});
return originalMethod.call(this, path, ...wrappedHandlers);
};
});
return router;
}
// Usage
const router = asyncRouter();
router.get('/users/:id', async (req, res) => {
// No try/catch needed - errors will be caught automatically
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
});
Solution 4: Express Async Errors Package
A popular solution is to use the express-async-errors package:
// At the very top of your application
require('express-async-errors');
const express = require('express');
const app = express();
// Now all async errors will be caught automatically
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
res.json(user);
});
Handling Promise Rejections Outside Express
Always set up global handlers for uncaught exceptions and unhandled promise rejections:
process.on('uncaughtException', (err) => {
console.error('UNCAUGHT EXCEPTION! 💥 Shutting down...');
console.error(err.name, err.message, err.stack);
// It's best to exit and let a process manager restart the app
process.exit(1);
});
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION! 💥 Shutting down...');
console.error(err.name, err.message, err.stack);
// Graceful shutdown
server.close(() => {
process.exit(1);
});
});
Organizing Error Handling Code
As your application grows, it's important to organize your error handling code effectively.
Modular Error Structure
// Example folder structure
project/
├── errors/
│ ├── index.js // Exports all errors
│ ├── AppError.js // Base error class
│ ├── DatabaseError.js // Database-related errors
│ ├── ValidationError.js // Validation errors
│ ├── AuthError.js // Authentication/authorization errors
│ └── NotFoundError.js // 404 errors
├── middleware/
│ └── errorHandler.js // Error middleware
├── utils/
│ └── catchAsync.js // Async error wrapper
└── app.js // Main app file
Error Factory Pattern
Use factories to create error instances with consistent formats:
// utils/errorFactory.js
const {
AppError,
ValidationError,
NotFoundError,
DatabaseError,
AuthenticationError,
AuthorizationError
} = require('../errors');
class ErrorFactory {
static validation(message, errors) {
return new ValidationError(message, errors);
}
static notFound(resource) {
return new NotFoundError(resource);
}
static database(message, originalError) {
const error = new DatabaseError(message);
error.originalError = originalError;
return error;
}
static authentication(message) {
return new AuthenticationError(message);
}
static authorization(message) {
return new AuthorizationError(message);
}
static badRequest(message, code = 'BAD_REQUEST') {
return new AppError(message, 400, code);
}
static internal(message = 'Internal server error', code = 'INTERNAL_ERROR') {
return new AppError(message, 500, code);
}
}
module.exports = ErrorFactory;
Centralized Error Configuration
Define error messages and codes in a central location:
// config/errors.js
const ERROR_TYPES = {
VALIDATION: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
INTERNAL: 'INTERNAL_ERROR',
DATABASE: 'DATABASE_ERROR',
// Add more as needed
};
const ERROR_MESSAGES = {
[ERROR_TYPES.VALIDATION]: 'Validation failed',
[ERROR_TYPES.NOT_FOUND]: 'Resource not found',
[ERROR_TYPES.UNAUTHORIZED]: 'Authentication required',
[ERROR_TYPES.FORBIDDEN]: 'Access forbidden',
[ERROR_TYPES.INTERNAL]: 'Internal server error',
[ERROR_TYPES.DATABASE]: 'Database operation failed',
// Add more as needed
};
module.exports = {
TYPES: ERROR_TYPES,
MESSAGES: ERROR_MESSAGES
};
Development vs. Production Error Handling
Error handling should adapt based on the environment to balance debugging needs with security concerns.
Environment-Specific Error Handling
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
// Clone error to avoid modification
let error = { ...err };
error.message = err.message;
error.stack = err.stack;
// Process error types (like we did before)
// ...
// Development error response (detailed)
if (process.env.NODE_ENV === 'development') {
return res.status(error.statusCode || 500).json({
success: false,
error: {
message: error.message,
code: error.code,
statusCode: error.statusCode,
stack: error.stack,
details: error.errors || null
}
});
}
// Production error response (limited info)
// Hide internal errors from users
if (!error.isOperational) {
error.message = 'Something went wrong';
error.statusCode = 500;
error.code = 'INTERNAL_ERROR';
}
return res.status(error.statusCode || 500).json({
success: false,
error: {
message: error.message,
code: error.code,
// No stack trace or detailed errors in production
}
});
};
Error Logging in Different Environments
// Development logging
if (process.env.NODE_ENV === 'development') {
console.error('ERROR:', err);
console.error('Stack:', err.stack);
}
// Production logging (structured for parsing)
if (process.env.NODE_ENV === 'production') {
const errorLog = {
timestamp: new Date().toISOString(),
requestId: req.id,
path: req.path,
method: req.method,
ip: req.ip,
error: {
name: err.name,
message: err.message,
code: err.code,
statusCode: err.statusCode,
stack: err.stack
}
};
// In production, you might use a logging service
console.error(JSON.stringify(errorLog));
// Or send to error monitoring service
Sentry.captureException(err);
}
Development vs. Production Response Example
Development Response:
{
"success": false,
"error": {
"message": "Cannot read property 'name' of undefined",
"code": "INTERNAL_ERROR",
"statusCode": 500,
"stack": "TypeError: Cannot read property 'name' of undefined\n at getUserName (/app/services/userService.js:15:20)\n at processRequest (/app/controllers/userController.js:24:12)\n at async /app/routes/userRoutes.js:14:5",
"details": null
}
}
Production Response:
{
"success": false,
"error": {
"message": "Something went wrong",
"code": "INTERNAL_ERROR"
}
}
Error Monitoring and Alerting
For production applications, it's crucial to monitor errors and set up alerts for critical issues.
Error Monitoring Services
Popular error monitoring services include:
- Sentry
- Rollbar
- New Relic
- Datadog
- LogRocket
Integrating Sentry Example
// Initialize Sentry at the top of your app
const Sentry = require('@sentry/node');
const express = require('express');
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
integrations: [
// Enable HTTP calls tracing
new Sentry.Integrations.Http({ tracing: true }),
// Enable Express middleware tracing
new Sentry.Integrations.Express({ app })
],
tracesSampleRate: 1.0
});
const app = express();
// RequestHandler creates a separate execution context
app.use(Sentry.Handlers.requestHandler());
// TracingHandler creates a trace for every incoming request
app.use(Sentry.Handlers.tracingHandler());
// Your routes here
// ...
// Set up error handlers
// The Sentry error handler must be before any other error middleware
app.use(Sentry.Handlers.errorHandler());
// Then add your custom error handler
app.use((err, req, res, next) => {
// Your custom error handling logic
// ...
});
Custom Error Tagging
Add tags to categorize and search errors:
// Enhanced error middleware with Sentry
const errorHandler = (err, req, res, next) => {
// Process error...
// For monitoring in production
if (process.env.NODE_ENV === 'production') {
// Add context to the error
Sentry.withScope(scope => {
// Add user context if available
if (req.user) {
scope.setUser({
id: req.user.id,
email: req.user.email
});
}
// Add request details
scope.setTag('route', req.path);
scope.setTag('method', req.method);
// Add error classification
scope.setTag('error_type', err.isOperational ? 'operational' : 'programming');
scope.setTag('status_code', error.statusCode || 500);
scope.setTag('error_code', error.code || 'INTERNAL_ERROR');
// Add extra context
scope.setExtra('body', req.body);
scope.setExtra('params', req.params);
scope.setExtra('query', req.query);
// Capture the error
Sentry.captureException(err);
});
}
// Send response to client...
};
Setting Up Error Alerts
Configure alerts for critical errors:
- Set up email notifications for critical errors
- Configure Slack/Teams integrations for real-time alerts
- Create dashboards to monitor error trends
- Set thresholds for error rates that trigger alerts
- Categorize errors by severity (critical, warning, info)
Most monitoring services provide these features out of the box.
Practical Exercise
Build a Complete Error Handling System
Create a robust error handling system for an Express.js API with the following components:
Requirements
- A base AppError class that extends Error
- Specialized error classes for common scenarios (NotFound, Validation, etc.)
- An async error handling utility to catch promise rejections
- A central error handling middleware that formats errors consistently
- Environment-specific error handling (dev vs. prod)
Steps
- Create the error class hierarchy
- Implement the catchAsync utility
- Set up global error handlers for unhandled exceptions
- Create the error handling middleware
- Test the system with various error scenarios
Testing Scenarios
- Synchronous errors in route handlers
- Asynchronous errors in async functions
- 404 errors for non-existent routes
- Validation errors for invalid input
- Database errors (simulate with try/throw)
Bonus Challenge
Extend your system to include:
- Error logging to a file
- Custom error codes and messages
- Request ID tracking for correlation
- A mock Sentry integration
Best Practices Summary
Error Handling Principles
- Be Consistent: Use the same error format throughout your application
- Be Informative: Provide helpful error messages that guide users to fix issues
- Be Secure: Never expose sensitive information in error responses
- Categorize Errors: Distinguish between operational and programming errors
- Centralize Handling: Use middleware for consistent error processing
- Log Effectively: Include context for debugging but respect privacy
- Handle Async Errors: Ensure all asynchronous code is properly error-handled
- Adapt to Environment: Show detailed errors in development, not production
- Monitor and Alert: Track errors and get notified about critical issues
- Use Custom Error Classes: Create specialized error types for different scenarios
Common Mistakes to Avoid
- Exposing stack traces or internal error details in production
- Inconsistent error response formats across different routes
- Missing error handling for asynchronous operations
- Generic error messages that don't help users understand what went wrong
- Not logging sufficient context for debugging purposes
- Not distinguishing between different types of errors
- Using HTTP status codes incorrectly
- Over-engineering error handling for simple applications
Further Reading
Next Steps
In our next lecture, we'll explore Asynchronous Error Handling, focusing on patterns and techniques for handling errors in asynchronous code, promises, and async/await functions.
Practice Activities
Basic Exercises
- Create a basic Express application with custom error classes for different HTTP status codes
- Implement a catchAsync utility function and use it in several route handlers
- Add a global error handler that formats errors differently in development and production
- Create a middleware that adds request IDs to each request and includes them in error responses
- Implement error logging to a file with rotation based on date
Advanced Project
Build a small "Error Management Service" for Express applications that includes:
- A complete error class hierarchy for common scenarios
- A configurable error middleware with environment-specific behavior
- Integration with a logging service (like Winston)
- Mock integration with error monitoring (like Sentry)
- A dashboard route that shows recent errors (in development only)
- Error response templates that follow REST API best practices