Understanding Error Handling in Express
Imagine you're running a restaurant. Even with the best chefs and most carefully planned menu, sometimes things go wrong: a dish might be undercooked, ingredients could run out, or a customer might have an allergic reaction. Just as a good restaurant needs procedures for handling these situations, your Express application needs robust error handling to manage unexpected situations gracefully.
In this guide, we'll explore how to create a sophisticated error handling system that catches problems before they reach your users and handles them appropriately. We'll start with the basics and build up to advanced error handling strategies that you can implement in your own applications.
Express's Default Error Handler
Express comes with a built-in error handler, much like a restaurant's basic fire safety system. It's there for emergencies, but you'll usually want something more sophisticated. Let's look at how the default handler works:
// This route will trigger the default error handler
app.get('/trigger-error', (req, res) => {
throw new Error('Something went wrong!');
});
// In development, you'll see detailed error information
// In production (NODE_ENV=production), you'll see just "Internal Server Error"
The default handler behaves differently in development and production environments:
// Development Output Example:
Error: Something went wrong!
at throwError (/app/routes/example.js:14:9)
at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
...
// Production Output:
Internal Server Error
Creating Your First Custom Error Handler
Just as a restaurant might have specific procedures for different types of incidents, we can create custom error handlers for our application. The key difference between regular middleware and error handling middleware is the number of parameters:
// Regular middleware: (req, res, next)
app.use((req, res, next) => {
console.log('Processing request...');
next();
});
// Error handling middleware: (err, req, res, next)
app.use((err, req, res, next) => {
console.error('An error occurred:', err.message);
res.status(500).json({
error: 'Something went wrong! Our team has been notified.'
});
});
Here's a more complete example of a basic custom error handler:
const express = require('express');
const app = express();
// Regular routes
app.get('/api/users', (req, res, next) => {
try {
// Simulate database error
throw new Error('Database connection failed');
} catch (err) {
next(err); // Pass error to error handler
}
});
// Custom error handler
app.use((err, req, res, next) => {
// Log error details (but not in production)
if (process.env.NODE_ENV !== 'production') {
console.error('Error details:', {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString()
});
}
// Send appropriate response to client
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: err.message
});
});
Creating Specialized Error Types
Just as a restaurant might handle a minor cooking delay differently from a serious kitchen fire, your application should handle different types of errors appropriately. Here's how to create custom error types:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
this.status = 400;
}
}
class AuthenticationError extends Error {
constructor(message) {
super(message);
this.name = 'AuthenticationError';
this.status = 401;
}
}
class DatabaseError extends Error {
constructor(message) {
super(message);
this.name = 'DatabaseError';
this.status = 503;
}
}
// Usage in routes
app.post('/api/users', (req, res, next) => {
if (!req.body.email) {
next(new ValidationError('Email is required'));
return;
}
// Continue with regular processing...
});
Implementing Multiple Error Handlers
Different errors often require different handling strategies. Here's how to implement multiple specialized error handlers:
// Logging error handler
app.use((err, req, res, next) => {
// Log error details
const errorLog = {
timestamp: new Date().toISOString(),
url: req.url,
method: req.method,
errorName: err.name,
errorMessage: err.message,
stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined
};
// In production, you might want to log to a service like CloudWatch or Splunk
console.error('Error Log:', errorLog);
// Pass error to next handler
next(err);
});
// Validation error handler
app.use((err, req, res, next) => {
if (err instanceof ValidationError) {
return res.status(400).json({
error: 'Validation Error',
details: err.message
});
}
next(err);
});
// Authentication error handler
app.use((err, req, res, next) => {
if (err instanceof AuthenticationError) {
return res.status(401).json({
error: 'Authentication Error',
message: 'Please login to continue'
});
}
next(err);
});
// Final catch-all error handler
app.use((err, req, res, next) => {
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: err.message
});
});
Handling Asynchronous Errors
Asynchronous operations require special attention in error handling. Here's how to properly handle async errors:
// Helper function to wrap async route handlers
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Using the async handler
app.get('/api/users', asyncHandler(async (req, res) => {
const users = await database.query('SELECT * FROM users');
res.json(users);
}));
// Alternative approach using try-catch
app.get('/api/products', async (req, res, next) => {
try {
const products = await database.query('SELECT * FROM products');
res.json(products);
} catch (err) {
next(err);
}
});
Production-Ready Error Handling
When your application goes to production, error handling becomes even more critical. Here's a production-ready error handling setup:
const express = require('express');
const app = express();
// Error logging service (example implementation)
class ErrorLogger {
static async logError(error, req) {
const errorDetails = {
timestamp: new Date().toISOString(),
url: req.url,
method: req.method,
ip: req.ip,
userId: req.user?.id,
errorName: error.name,
errorMessage: error.message,
stack: error.stack
};
// In production, you would send this to your logging service
if (process.env.NODE_ENV === 'production') {
// await logToCloudWatch(errorDetails);
// or
// await logToSplunk(errorDetails);
} else {
console.error('Error Details:', errorDetails);
}
}
}
// Comprehensive error handling system
app.use(async (err, req, res, next) => {
// 1. Log the error
await ErrorLogger.logError(err, req);
// 2. Determine error type and appropriate response
const errorResponse = {
status: err.status || 500,
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: err.message,
code: err.code || 'INTERNAL_ERROR'
};
// 3. Add extra context for specific error types
if (err instanceof ValidationError) {
errorResponse.status = 400;
errorResponse.code = 'VALIDATION_ERROR';
if (err.details) {
errorResponse.details = err.details;
}
}
// 4. Handle specific error scenarios
switch (err.name) {
case 'TokenExpiredError':
errorResponse.status = 401;
errorResponse.message = 'Your session has expired. Please login again.';
errorResponse.code = 'SESSION_EXPIRED';
break;
case 'SequelizeConnectionError':
errorResponse.status = 503;
errorResponse.message = 'Service temporarily unavailable';
errorResponse.code = 'DATABASE_ERROR';
break;
}
// 5. Send response
res.status(errorResponse.status).json({
error: {
message: errorResponse.message,
code: errorResponse.code,
...(errorResponse.details && { details: errorResponse.details })
}
});
});
Error Handling Best Practices
To ensure your error handling is robust and maintainable, follow these key practices:
1. Centralize Error Handling
// errors/types.js
class AppError extends Error {
constructor(message, status) {
super(message);
this.status = status;
this.timestamp = new Date().toISOString();
}
}
// errors/handler.js
const errorHandler = {
log(err, req) {
// Centralized logging logic
},
format(err) {
// Centralized error formatting logic
},
respond(err, req, res) {
// Centralized response logic
}
};
2. Use Environment-Appropriate Responses
const formatErrorResponse = (err) => {
const isProd = process.env.NODE_ENV === 'production';
return {
message: isProd ? 'An error occurred' : err.message,
...(isProd ? {} : { stack: err.stack }),
code: err.code,
status: err.status
};
};
3. Implement Request Correlation
app.use((req, res, next) => {
req.correlationId = require('crypto').randomUUID();
next();
});
app.use((err, req, res, next) => {
console.error(`Error [${req.correlationId}]:`, err);
res.status(500).json({
error: 'An error occurred',
correlationId: req.correlationId
});
});