Understanding Error Handling in Express
Effective error handling is one of the most important aspects of building robust web applications. When things go wrong—and they inevitably will—your application should gracefully handle errors, provide meaningful feedback to users, and maintain security by not exposing sensitive information.
Let's apply George Polya's problem-solving approach to understand and implement error handling for our Express application:
Step 1: Understand the Problem
In an Express API, many things can go wrong:
- A client might request a resource that doesn't exist
- Database operations might fail due to validation errors
- Authentication might fail
- External services might be unavailable
- The server might encounter unexpected runtime errors
A good error handling system should:
- Catch all types of errors that might occur
- Format errors consistently for the client
- Include appropriate HTTP status codes
- Provide helpful error messages without exposing sensitive details
- Log errors for debugging and monitoring
Step 2: Devise a Plan
- Create middleware for catching unmatched routes (404 errors)
- Build middleware for handling Sequelize database errors
- Implement a general error formatter middleware
- Connect all middleware to the Express application
- Test the error handling system
Step 3: Execute the Plan
Now, let's implement our error handling system:
Resource Not Found Error Handler
The first middleware we'll create catches requests for resources that don't exist in our API. These should return a 404 status code with a helpful message.
This middleware should be placed after all your defined routes. It acts as a "catch-all" for any routes that weren't matched by your specific route handlers.
// File: backend/app.js
// ... (after all route definitions)
// Catch unhandled requests and forward to error handler
app.use((_req, _res, next) => {
const err = new Error("The requested resource couldn't be found.");
err.title = "Resource Not Found";
err.errors = { message: "The requested resource couldn't be found." };
err.status = 404;
next(err);
});
Let's understand what's happening here:
- The middleware function receives the request, response, and next function
- We use underscore prefixes (_req, _res) to indicate that we're not using those parameters
- We create a new Error object with a descriptive message
- We add additional properties to the error: title, errors object, and status code
- We pass the error to the next middleware by calling next(err)
This pattern of calling next(err) is crucial in Express error handling. It skips all remaining non-error-handling middleware and passes control to the next error-handling middleware.
Sequelize Error Handler
When working with databases through Sequelize, we often encounter validation errors. These might occur when:
- A unique constraint is violated (e.g., duplicate username)
- A required field is missing
- A field fails validation (e.g., email format, length requirements)
Let's create middleware that catches Sequelize validation errors and formats them consistently:
// File: backend/app.js
// ... (after the resource not found handler)
const { ValidationError } = require('sequelize');
// Process sequelize errors
app.use((err, _req, _res, next) => {
// Check if error is a Sequelize validation error
if (err instanceof ValidationError) {
let errors = {};
// Extract validation error messages
for (let error of err.errors) {
errors[error.path] = error.message;
}
// Add additional error properties
err.title = 'Validation error';
err.errors = errors;
}
next(err);
});
This middleware:
- Checks if the error is an instance of Sequelize's ValidationError
- If it is, transforms the error format to a more client-friendly structure
- Maps each validation error to its corresponding field path
- Adds a descriptive title to the error
- Passes the modified error to the next error handler
The resulting error format will help the client understand exactly which fields failed validation and why, making it easier to display appropriate error messages in forms or user interfaces.
Error Formatter Middleware
The final piece of our error handling system is a general error formatter. This middleware will:
- Format all errors consistently before sending them to the client
- Set the appropriate HTTP status code
- Include error details for development but not for production
// File: backend/app.js
// ... (after the Sequelize error handler)
// Error formatter
app.use((err, _req, res, _next) => {
// Set the status code
res.status(err.status || 500);
// Log the error for server-side debugging
console.error(err);
// Send JSON response with error details
res.json({
title: err.title || 'Server Error',
message: err.message,
errors: err.errors,
stack: isProduction ? null : err.stack
});
});
This error formatter shows the power of Express's error handling middleware:
- It sets the HTTP status code to the error's status or defaults to 500 (Internal Server Error)
- It logs the full error to the server console for debugging
- It returns a JSON response with error details
- It includes the error stack trace in development but not in production
The error stack trace is particularly important for debugging but should never be exposed in production as it might reveal implementation details that could be exploited by attackers.
Connecting Error Handlers to Express
The order in which you add these middleware functions to your Express application is crucial. They should be added after all your route definitions but before the application is exported or started.
Here's how your app.js file should be structured:
// File: backend/app.js
const express = require('express');
const morgan = require('morgan');
// ... other imports
const { ValidationError } = require('sequelize');
const { environment } = require('./config');
const isProduction = environment === 'production';
// Import routes
const routes = require('./routes');
const app = express();
// Connect regular middleware
app.use(morgan('dev'));
app.use(express.json());
// ... other middleware
// Connect route handlers
app.use(routes);
// Connect error handlers (order matters!)
// 1. Resource not found handler
app.use((_req, _res, next) => {
const err = new Error("The requested resource couldn't be found.");
err.title = "Resource Not Found";
err.errors = { message: "The requested resource couldn't be found." };
err.status = 404;
next(err);
});
// 2. Sequelize error handler
app.use((err, _req, _res, next) => {
if (err instanceof ValidationError) {
let errors = {};
for (let error of err.errors) {
errors[error.path] = error.message;
}
err.title = 'Validation error';
err.errors = errors;
}
next(err);
});
// 3. Error formatter
app.use((err, _req, res, _next) => {
res.status(err.status || 500);
console.error(err);
res.json({
title: err.title || 'Server Error',
message: err.message,
errors: err.errors,
stack: isProduction ? null : err.stack
});
});
module.exports = app;
The order of middleware is important in Express:
- Regular middleware is executed first, in the order it's connected
- Route handlers are executed next when they match a request
- If no route matches, the 404 handler is triggered
- Error-handling middleware (with four parameters) is executed only when an error occurs
- Multiple error handlers can be chained, each processing and passing the error to the next
Testing Error Handling
Testing Resource Not Found (404) Errors
To test that your 404 error handler works, try accessing a route that doesn't exist:
// Open your browser or use a tool like Postman or curl
GET http://localhost:8000/nonexistent-route
You should receive a JSON response like:
{
"title": "Resource Not Found",
"message": "The requested resource couldn't be found.",
"errors": {
"message": "The requested resource couldn't be found."
},
"stack": "Error: The requested resource couldn't be found.\n at ..."
}
Testing Sequelize Validation Errors
To test Sequelize validation errors, try creating a user with invalid data. For example, try to sign up with an email that's already in use:
// First get a CSRF token
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
// Try to create a user with an existing email
return fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': csrfToken
},
body: JSON.stringify({
email: 'demo@user.io', // This email already exists in our seed data
username: 'testuser',
password: 'password123',
firstName: 'Test',
lastName: 'User'
})
});
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));
You should see a validation error response like:
{
"title": "Validation error",
"message": "Validation error",
"errors": {
"email": "email must be unique"
},
"stack": "..."
}
Testing Other Error Types
You can also test how your application handles other errors, such as authentication failures:
// Try to access a protected route without authentication
fetch('/api/protected-resource', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));
If you've set up authentication correctly, you should see an unauthorized error response.
Understanding Express Error Handling
Step 4: Review the Solution
Let's review what we've learned about error handling in Express:
Express Error Handling Flow
Express has a special way of handling errors:
- When an error occurs, you can pass it to the next function:
next(err) - This skips all remaining regular middleware
- Control jumps to the first error-handling middleware (functions with four parameters)
- Error handlers can modify the error or pass it along with
next(err) - The last error handler typically sends a response to the client
Error Handler Middleware Structure
Error-handling middleware in Express has a specific signature with four parameters:
(err, req, res, next) => { /* handle error */ }
Express recognizes this pattern and only calls these functions when an error has occurred.
Types of Errors We've Handled
Our implementation specifically handles:
- 404 Not Found errors: When a client requests a non-existent resource
- Sequelize validation errors: Database constraint or validation failures
- General server errors: Any other errors that might occur
Best Practices We've Applied
Our error handling system follows several best practices:
- Consistent error format: All errors follow the same JSON structure
- Appropriate status codes: Different error types have different HTTP status codes
- Security considerations: Stack traces are only included in development
- Detailed error messages: Field-specific validation errors help clients understand issues
- Logging: All errors are logged to the console for debugging
Advanced Error Handling Techniques
Custom Error Classes
For more sophisticated applications, you might want to create custom error classes for different error types:
// File: backend/utils/errors.js
class ValidationError extends Error {
constructor(errors) {
super('Validation error');
this.errors = errors;
this.status = 400;
this.title = 'Validation error';
}
}
class AuthenticationError extends Error {
constructor(message = 'Authentication required') {
super(message);
this.status = 401;
this.title = 'Authentication required';
this.errors = { message };
}
}
class ForbiddenError extends Error {
constructor(message = 'Forbidden') {
super(message);
this.status = 403;
this.title = 'Forbidden';
this.errors = { message };
}
}
module.exports = {
ValidationError,
AuthenticationError,
ForbiddenError
};
Then you can use these custom error classes throughout your application:
// In a route handler
const { AuthenticationError } = require('../utils/errors');
router.get('/protected', (req, res, next) => {
if (!req.user) {
return next(new AuthenticationError());
}
// Continue with protected route logic...
});
Async Error Handling
Handling errors in asynchronous functions requires special attention. Express doesn't automatically catch errors in async functions or Promises.
We've been using the express-async-errors package, which patches Express to handle async errors. Without this package, you would need to catch errors explicitly:
// Without express-async-errors
router.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findByPk(req.params.id);
if (!user) {
return next(new Error('User not found'));
}
return res.json({ user });
} catch (err) {
next(err);
}
});
// With express-async-errors (much cleaner)
router.get('/users/:id', async (req, res, next) => {
const user = await User.findByPk(req.params.id);
if (!user) {
return next(new Error('User not found'));
}
return res.json({ user });
});
Centralizing Error Handling Logic
For larger applications, you might want to centralize your error handling logic in a separate module:
// File: backend/utils/errorHandler.js
const { ValidationError } = require('sequelize');
const { environment } = require('../config');
const isProduction = environment === 'production';
// Resource not found handler
const handleNotFound = (_req, _res, next) => {
const err = new Error("The requested resource couldn't be found.");
err.title = "Resource Not Found";
err.errors = { message: "The requested resource couldn't be found." };
err.status = 404;
next(err);
};
// Sequelize error handler
const handleSequelizeErrors = (err, _req, _res, next) => {
if (err instanceof ValidationError) {
let errors = {};
for (let error of err.errors) {
errors[error.path] = error.message;
}
err.title = 'Validation error';
err.errors = errors;
}
next(err);
};
// Final error formatter
const formatError = (err, _req, res, _next) => {
res.status(err.status || 500);
console.error(err);
res.json({
title: err.title || 'Server Error',
message: err.message,
errors: err.errors,
stack: isProduction ? null : err.stack
});
};
module.exports = {
handleNotFound,
handleSequelizeErrors,
formatError
};
Then in your app.js:
// File: backend/app.js
const {
handleNotFound,
handleSequelizeErrors,
formatError
} = require('./utils/errorHandler');
// ... (after routes)
app.use(handleNotFound);
app.use(handleSequelizeErrors);
app.use(formatError);
This approach makes your app.js file cleaner and allows you to reuse error handling logic across different Express applications.
Error Handling in Production
Security Considerations
In production environments, error handling should prioritize security:
- Never expose stack traces - They reveal implementation details
- Use generic error messages for unexpected errors
- Log details server-side but limit what's sent to clients
- Don't expose database column names directly in validation errors
Error Logging
In production, simple console.error() logs may not be sufficient. Consider using a logging service or library like Winston, Bunyan, or Pino:
// Example with Winston
const winston = require('winston');
const logger = winston.createLogger({
level: 'error',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log' }),
new winston.transports.Console({
format: winston.format.simple()
})
]
});
// In error handler
app.use((err, _req, res, _next) => {
// Log details for operators
logger.error({
message: err.message,
stack: err.stack,
status: err.status,
timestamp: new Date().toISOString()
});
// Send limited info to client
res.status(err.status || 500);
res.json({
title: err.title || 'Server Error',
message: isProduction ? 'An unexpected error occurred' : err.message,
errors: isProduction ? { message: 'An unexpected error occurred' } : err.errors
});
});
Monitoring and Alerting
For production applications, implement monitoring and alerting for errors:
- Use error tracking services like Sentry, Rollbar, or New Relic
- Set up alerting for unusual error patterns
- Monitor error rates and response times
// Example with Sentry
const Sentry = require('@sentry/node');
Sentry.init({ dsn: 'your-sentry-dsn' });
// Add to your Express app
app.use(Sentry.Handlers.requestHandler());
// After your regular routes, but before error handlers
app.use(Sentry.Handlers.errorHandler());
// Then your regular error handlers
app.use((err, _req, res, _next) => {
// ...
});
Real-World Applications of Error Handling
User Experience Considerations
Good error handling contributes significantly to user experience:
- Form validation: Clearly indicate which fields have errors and why
- Authentication errors: Provide helpful but not overly specific messages
- Permission errors: Clearly explain what the user can't do and possibly why
- Server errors: Reassure users and provide recovery options
API Design Patterns
Error handling in APIs typically follows these patterns:
- HTTP status codes indicate the type of error (400s for client errors, 500s for server errors)
- JSON error bodies provide details about what went wrong
- Field-level validation errors help clients correct form submissions
- Error codes can help clients programmatically handle specific error cases
Examples from Popular APIs
Let's look at how some popular APIs handle errors:
Stripe API
// Stripe error response example
{
"error": {
"code": "resource_missing",
"doc_url": "https://stripe.com/docs/error-codes/resource-missing",
"message": "No such customer: cus_123456789",
"param": "customer",
"type": "invalid_request_error"
}
}
GitHub API
// GitHub error response example
{
"message": "Validation Failed",
"errors": [
{
"resource": "Issue",
"field": "title",
"code": "missing_field"
}
],
"documentation_url": "https://docs.github.com/rest/reference/issues#create-an-issue"
}
These examples show how well-designed error responses can:
- Clearly indicate what went wrong
- Provide specific field-level details
- Include links to documentation
- Use error codes for programmatic handling
Conclusion
Effective error handling is a crucial but often overlooked aspect of web application development. It not only improves the developer experience but also significantly enhances the user experience and application security.
In this guide, we've explored how to:
- Create middleware for handling different types of errors in Express
- Format errors consistently for API responses
- Handle Sequelize database validation errors
- Implement security best practices for error handling
- Test error handling functionality
By implementing the error handling system described in this guide, you've significantly improved your Express application's robustness. Users will receive appropriate feedback when things go wrong, and you'll have the tools to diagnose and fix issues quickly.
Next Steps
To further enhance your error handling:
- Implement custom error classes for different types of application errors
- Add more detailed logging in production environments
- Integrate with error tracking services like Sentry
- Create client-side error handling that works with your API error format
- Add comprehensive tests for your error handling system
Remember that good error handling is an ongoing process. As your application grows and evolves, you'll likely need to expand your error handling to cover new cases and scenarios.