How to Handle Errors in Express
How to Handle Errors in Express Express.js is one of the most popular Node.js frameworks for building scalable and high-performance web applications and APIs. While its minimalist design makes it flexible and easy to learn, it also places significant responsibility on developers to manage errors effectively. Without proper error handling, applications can crash unexpectedly, expose sensitive infor
How to Handle Errors in Express
Express.js is one of the most popular Node.js frameworks for building scalable and high-performance web applications and APIs. While its minimalist design makes it flexible and easy to learn, it also places significant responsibility on developers to manage errors effectively. Without proper error handling, applications can crash unexpectedly, expose sensitive information, or deliver poor user experiences. Handling errors in Express is not an optional featureits a critical component of production-ready software.
This guide provides a comprehensive, step-by-step approach to mastering error handling in Express. Whether you're building a REST API, a full-stack application, or a microservice, understanding how to catch, log, respond to, and recover from errors will significantly improve your applications reliability, security, and maintainability. Well cover everything from basic middleware to advanced patterns, best practices, real-world examples, and essential toolsall designed to help you build resilient applications.
Step-by-Step Guide
Understanding Express Error Handling Mechanics
Express.js follows a middleware-based architecture. Each request passes through a sequence of middleware functions, and each function can either pass control to the next middleware using next() or terminate the request-response cycle by sending a response.
When an error occurswhether from a thrown exception, a rejected Promise, or an explicit call to next(error)Express skips all remaining non-error middleware and looks for the first error-handling middleware. Error-handling middleware functions are defined with four parameters: (err, req, res, next). If you define a function with four parameters, Express treats it as an error handler.
For example:
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
This function will catch any error passed to next() anywhere in your route stack, provided its placed after all other middleware and routes.
Step 1: Use try-catch with Async/Await
One of the most common sources of unhandled errors in Express applications comes from asynchronous code, especially when using async/await. If you dont wrap asynchronous operations in a try-catch block, any thrown error will not be caught by Expresss default error handler.
Consider this flawed route:
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
If User.findById() throws an error (e.g., due to a database connection failure), the error will not be caught, and the server may crash or hang.
The correct approach is to wrap it in a try-catch:
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (err) {
next(err); // Pass error to error-handling middleware
}
});
By calling next(err), you delegate error handling to your centralized error middleware, ensuring consistent responses and preventing uncaught exceptions.
Step 2: Create a Centralized Error-Handling Middleware
Instead of repeating try-catch blocks in every route, define a single error-handling middleware that catches all errors. This promotes consistency, reduces code duplication, and makes logging and response formatting easier.
Create a file named errorHandler.js:
const errorHandler = (err, req, res, next) => {
console.error(err.stack);
// Default status code
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
// Log error details in development
if (process.env.NODE_ENV === 'development') {
return res.status(statusCode).json({
message,
error: err,
stack: err.stack
});
}
// In production, avoid exposing stack traces
res.status(statusCode).json({
message,
error: {}
});
};
module.exports = errorHandler;
Then, in your main application file (e.g., app.js), import and use it after all routes:
const express = require('express');
const app = express();
const errorHandler = require('./errorHandler');
// Middleware
app.use(express.json());
// Routes
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
res.json(user);
} catch (err) {
next(err);
}
});
// Error handling middleware MUST be placed after all routes
app.use(errorHandler);
app.listen(3000, () => {
console.log('Server running on port 3000');
});
This structure ensures that any error thrown or passed to next() will be handled uniformly across your application.
Step 3: Create Custom Error Classes
Express doesnt distinguish between different types of errors by default. To handle different scenarios appropriatelysuch as validation errors, authentication failures, or database timeoutsyou should create custom error classes.
Create a file named CustomError.js:
class CustomError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = statusCode >= 400 && statusCode
this.isOperational = true; // Marks it as a known, expected error
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = CustomError;
Now, you can throw specific errors in your routes:
const CustomError = require('./CustomError');
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
throw new CustomError('User not found', 404);
}
res.json(user);
} catch (err) {
next(err);
}
});
Update your error handler to respond differently based on error type:
const errorHandler = (err, req, res, next) => {
console.error(err.stack);
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal Server Error';
// If it's a custom error, use its properties
if (err.isOperational) {
return res.status(statusCode).json({
status: err.status,
message
});
}
// Log non-operational errors (bugs) for debugging
if (process.env.NODE_ENV === 'development') {
return res.status(statusCode).json({
message,
error: err,
stack: err.stack
});
}
// In production, only show generic message for bugs
res.status(500).json({
status: 'error',
message: 'Something went wrong!'
});
};
module.exports = errorHandler;
This approach separates expected errors (e.g., user input validation failures) from unexpected bugs (e.g., database connection lost). It allows you to respond appropriately to each type of failure without exposing internal system details to users.
Step 4: Handle Uncaught Exceptions and Rejections
Even with proper error handling in routes, some errors may escape your middlewareespecially unhandled Promise rejections or asynchronous errors outside of route handlers.
To prevent your Node.js process from crashing, add global error handlers:
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
process.exit(1); // Exit gracefully
});
// Handle unhandled Promise rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});
Place these at the top of your main application file, before any routes or middleware. Note that uncaughtException should be used cautiouslyideally, your application should be structured to avoid these entirely. These handlers are a safety net, not a substitute for proper error handling.
Step 5: Validate Input with Middleware
Many errors in Express applications stem from invalid or malformed input. Use validation middleware to catch these early.
Install a validation library like express-validator:
npm install express-validator
Use it to validate request data:
const { body, validationResult } = require('express-validator');
app.post('/users',
body('name').notEmpty().withMessage('Name is required'),
body('email').isEmail().withMessage('Valid email is required'),
async (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(new CustomError('Validation failed', 400));
}
try {
const user = await User.create(req.body);
res.status(201).json(user);
} catch (err) {
next(err);
}
}
);
By validating input before hitting your business logic, you reduce the likelihood of database errors, type mismatches, and security vulnerabilities.
Step 6: Log Errors for Debugging and Monitoring
Production applications require robust logging. Use a logging library like winston or morgan to record errors systematically.
npm install winston
Create a logger in logger.js:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
}
module.exports = logger;
Update your error handler to use the logger:
const logger = require('./logger');
const errorHandler = (err, req, res, next) => {
logger.error({
message: err.message,
stack: err.stack,
method: req.method,
url: req.url,
timestamp: new Date().toISOString()
});
// ... rest of error handling logic
};
This ensures every error is logged with contextrequest method, URL, and timestampmaking it easier to trace issues in production logs.
Step 7: Test Your Error Handling
Never assume your error handling works. Write unit and integration tests to verify that your middleware responds correctly to different error types.
Using supertest and jest:
const request = require('supertest');
const app = require('../app');
describe('Error Handling', () => {
test('returns 404 for non-existent user', async () => {
const res = await request(app).get('/users/999999');
expect(res.status).toBe(404);
expect(res.body.message).toBe('User not found');
});
test('returns 500 for unhandled database error', async () => {
// Mock database to throw error
jest.spyOn(User, 'findById').mockImplementation(() => {
throw new Error('Database timeout');
});
const res = await request(app).get('/users/1');
expect(res.status).toBe(500);
expect(res.body.message).toBe('Something went wrong!');
});
});
Testing error paths ensures your application behaves predictably under failure conditions.
Best Practices
Always Use Next() to Pass Errors
Never use res.status(500).send() inside a route when an error occurs. Always use next(err) to pass the error to your centralized error handler. This ensures consistent formatting, logging, and response structure across your entire application.
Separate Operational Errors from Programming Errors
Operational errors are expected and recoverable: invalid input, authentication failures, resource not found. Programming errors are bugs: null references, unhandled database queries, syntax errors.
Use the isOperational flag (as shown earlier) to distinguish between them. Only expose operational errors to clients. Programming errors should be logged internally and result in a generic 500 response in production.
Never Expose Stack Traces in Production
Stack traces contain sensitive information about your codebase, file paths, and dependencies. In production, always return a generic error message like Something went wrong. This protects your application from reconnaissance attacks and prevents attackers from exploiting known vulnerabilities.
Use HTTP Status Codes Correctly
Use appropriate HTTP status codes to communicate the nature of the error:
- 400 Bad Request Invalid client input
- 401 Unauthorized Missing or invalid authentication
- 403 Forbidden Authenticated but insufficient permissions
- 404 Not Found Resource does not exist
- 429 Too Many Requests Rate limiting triggered
- 500 Internal Server Error Unexpected server failure
- 502 Bad Gateway Downstream service failed
- 503 Service Unavailable Server temporarily overloaded
Consistent status codes make it easier for frontend clients and API consumers to handle responses programmatically.
Implement Rate Limiting
Malicious users may attempt to overwhelm your server with requests. Use express-rate-limit to prevent abuse:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use('/api/', limiter);
This prevents denial-of-service attacks and reduces the likelihood of server crashes due to traffic spikes.
Use Environment-Specific Error Responses
Always check process.env.NODE_ENV to determine whether to return detailed error messages. In development, expose full error details for debugging. In staging and production, return minimal, user-friendly messages.
Monitor Error Trends with APM Tools
Use Application Performance Monitoring (APM) tools like Sentry, New Relic, or Datadog to track errors in real time. These tools automatically capture stack traces, user context, and performance metrics, helping you identify and fix issues before users report them.
Gracefully Handle Database and External Service Failures
External dependencies (databases, APIs, caches) can fail. Always wrap calls to them in try-catch blocks and implement retry logic or fallback responses.
Example with retry logic:
const retry = require('async-retry');
app.get('/data', async (req, res, next) => {
try {
const data = await retry(
async (bail) => {
const result = await externalApi.getData();
if (result.error) bail(new Error('External API returned error'));
return result;
},
{ retries: 3, minTimeout: 1000 }
);
res.json(data);
} catch (err) {
next(new CustomError('Service temporarily unavailable', 503));
}
});
Document Your Error Responses
If youre building an API, document your error responses in your API documentation. Include expected status codes, response formats, and possible error messages. This helps frontend developers and third-party consumers handle errors correctly.
Tools and Resources
Essential npm Packages
- express-validator Input validation middleware
- winston Flexible logging library
- express-rate-limit Prevent API abuse
- async-retry Retry failed operations
- @sentry/node Real-time error monitoring
- morgan HTTP request logging
- supertest HTTP testing library
Logging and Monitoring Platforms
- Sentry Excellent for catching JavaScript errors with full stack traces and user context
- LogRocket Session replay and error tracking for frontend and backend
- Datadog Full-stack observability with metrics, logs, and traces
- New Relic Performance monitoring with deep code-level insights
- ELK Stack (Elasticsearch, Logstash, Kibana) Self-hosted log aggregation and visualization
Testing Frameworks
- Jest Popular testing framework with built-in mocking
- Mocha + Chai Traditional testing combo with rich assertion libraries
- Supertest Test Express apps via HTTP requests
Documentation Tools
- Swagger (OpenAPI) Generate interactive API documentation from code annotations
- Postman Test and document APIs with collections and environments
Recommended Reading
- Node.js Design Patterns by Mario Casciaro Covers error handling patterns in depth
- Express.js Official Documentation https://expressjs.com/en/guide/error-handling.html
- MDN Web Docs HTTP Status Codes: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
- OWASP Top Ten Security best practices: https://owasp.org/www-project-top-ten/
Real Examples
Example 1: API Endpoint with Comprehensive Error Handling
Lets build a real-world user registration endpoint with full error handling:
const express = require('express');
const { body, validationResult } = require('express-validator');
const CustomError = require('./CustomError');
const logger = require('./logger');
const User = require('./models/User');
const router = express.Router();
router.post('/register',
body('email').isEmail().withMessage('Valid email required'),
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
async (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return next(new CustomError('Validation failed', 400));
}
try {
const existingUser = await User.findOne({ email: req.body.email });
if (existingUser) {
return next(new CustomError('Email already in use', 409));
}
const user = await User.create(req.body);
res.status(201).json({
message: 'User created successfully',
user: { id: user._id, email: user.email }
});
} catch (err) {
if (err.code === 11000) {
return next(new CustomError('Email already exists', 409));
}
logger.error({
message: 'Failed to create user',
error: err.message,
data: req.body,
timestamp: new Date().toISOString()
});
next(new CustomError('Registration failed', 500));
}
}
);
module.exports = router;
When this endpoint is called with invalid data, it returns a 400 with a clear message. If the email is taken, it returns 409. If the database fails unexpectedly, it logs the error and returns a 500 without exposing internal details.
Example 2: Middleware for Authentication Errors
Create a middleware that checks for valid tokens and throws appropriate errors:
const jwt = require('jsonwebtoken');
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return next(new CustomError('Access token required', 401));
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return next(new CustomError('Token expired', 401));
}
return next(new CustomError('Invalid token', 403));
}
req.user = user;
next();
});
};
module.exports = authenticateToken;
Then use it in a protected route:
app.get('/profile', authenticateToken, (req, res) => {
res.json(req.user);
});
Each authentication failure results in a clear, standardized response.
Example 3: Global Error Handler with Sentry Integration
Integrate Sentry to automatically report errors:
const Sentry = require('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
});
const errorHandler = (err, req, res, next) => {
Sentry.captureException(err);
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal Server Error';
if (err.isOperational) {
return res.status(statusCode).json({
status: err.status,
message
});
}
if (process.env.NODE_ENV === 'development') {
return res.status(statusCode).json({
message,
error: err,
stack: err.stack
});
}
res.status(500).json({
status: 'error',
message: 'Something went wrong!'
});
};
module.exports = errorHandler;
Now every unhandled error is reported to Sentry, and your team receives alerts with full contextwithout exposing sensitive data to end users.
FAQs
What happens if I dont use next() in Express error handling?
If you throw an error but never call next(err), Express wont know to invoke your error-handling middleware. The request will hang indefinitely, or if the error is uncaught, it may crash the Node.js process. Always use next() to delegate errors.
Can I have multiple error-handling middleware functions?
Yes. Express will invoke the first error-handling middleware that matches the error type. You can chain themfor example, one for validation errors and another for database errors. However, its better to consolidate them into one handler with conditional logic for maintainability.
How do I handle errors in async route handlers without try-catch?
Use a utility function like asyncHandler to wrap async routes:
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users', asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
}));
This eliminates the need to write try-catch in every route.
Why should I avoid logging sensitive data?
Logging passwords, tokens, API keys, or personally identifiable information (PII) creates security risks. If logs are compromised, attackers can gain access to user accounts or internal systems. Always sanitize logs before writing them to disk or sending them to external services.
How do I test if my error handler works?
Use Supertest to simulate invalid requests and verify the response status and body. For example, send a POST request with missing fields and assert that you receive a 400 with the correct error message.
Is it okay to use process.exit() in error handlers?
Only in extreme caseslike critical system failures. In most cases, its better to let the error handler return a 500 and let the process continue. Modern process managers like PM2 or Docker can restart crashed instances automatically.
Should I handle errors on the frontend too?
Absolutely. Even with perfect backend error handling, network failures, timeouts, or CORS issues can occur. Always use try-catch or .catch() in frontend HTTP calls and display user-friendly messages like Unable to connect. Please try again.
Conclusion
Handling errors in Express is not just about preventing crashesits about building trust, ensuring reliability, and delivering a professional user experience. A well-structured error-handling system transforms unpredictable failures into predictable, manageable events.
By following the practices outlined in this guidecentralizing error handling, creating custom error classes, validating inputs, logging intelligently, and testing thoroughlyyoull build applications that are not only more stable but also easier to debug and maintain. Remember: errors are inevitable. How you respond to them defines the quality of your software.
Invest time upfront to implement robust error handling. The payoff comes in reduced downtime, fewer support tickets, and higher user satisfaction. In production, the difference between a good application and a great one often lies in how gracefully it handles failure.