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 robust, scalable web applications and APIs. While its minimalist design makes it flexible and powerful, it also places the responsibility of error handling squarely on the developer. Without proper error management, applications can crash silently, expose sensitive information, deliver poor user e

Nov 10, 2025 - 12:39
Nov 10, 2025 - 12:39
 1

How to Handle Errors in Express

Express.js is one of the most popular Node.js frameworks for building robust, scalable web applications and APIs. While its minimalist design makes it flexible and powerful, it also places the responsibility of error handling squarely on the developer. Without proper error management, applications can crash silently, expose sensitive information, deliver poor user experiences, or become vulnerable to security exploits. Handling errors effectively in Express is not optionalits a fundamental requirement for production-grade applications.

This comprehensive guide walks you through every aspect of error handling in Express, from basic middleware patterns to advanced strategies for logging, categorizing, and responding to errors. Whether you're building a REST API, a real-time service, or a full-stack application, mastering error handling will improve your apps reliability, maintainability, and user trust.

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 errors are handled in a special type of middleware that takes four parameters: err, req, res, and next. Unlike regular middleware (which takes three parameters), error-handling middleware must have four to be recognized by Express as an error handler.

When an error is passed to next()either explicitly via next(new Error('Something went wrong')) or implicitly through an uncaught exception or rejected PromiseExpress skips all remaining non-error middleware and jumps to the first error-handling middleware it finds.

If no error-handling middleware is defined, Express will respond with a default error message, which is often unhelpful and potentially dangerous in production.

Step 1: Use Try-Catch with Async/Await

One of the most common sources of unhandled errors in Express applications comes from asynchronous code. When using async/await, failing to wrap operations in try/catch blocks can result in uncaught promise rejections, which crash your Node.js process unless properly handled.

Instead of writing:

app.get('/users/:id', async (req, res) => {

const user = await User.findById(req.params.id);

res.json(user);

});

Always wrap 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

}

});

This ensures that any errorwhether from a database query, validation failure, or network timeoutis passed to your centralized error handler.

Step 2: Create a Centralized Error-Handling Middleware

Instead of repeating try/catch blocks everywhere, create a single, reusable error-handling middleware that catches all errors and responds consistently.

Place this middleware after all your routes:

// errorHandler.js

const errorHandler = (err, req, res, next) => {

console.error(err.stack); // Log the full error stack

// Default status code

const statusCode = err.statusCode || 500;

const message = err.message || 'Internal Server Error';

// Avoid exposing stack traces in production

const errorResponse = {

success: false,

message,

...(process.env.NODE_ENV === 'development' && { stack: err.stack })

};

res.status(statusCode).json(errorResponse);

};

module.exports = errorHandler;

Then, in your main app file:

const express = require('express');

const errorHandler = require('./middleware/errorHandler');

const app = express();

// ... your routes here ...

// Error-handling middleware MUST be defined after all other middleware and routes

app.use(errorHandler);

This pattern ensures that every errorwhether thrown manually, from a database driver, or from a malformed requestgets processed by the same logic, reducing inconsistency and improving maintainability.

Step 3: Handle 404 Errors Explicitly

Express does not automatically send a 404 response when a route is not found. You must define a catch-all route at the very end of your route definitions to handle unmatched paths.

// Place this AFTER all other routes

app.use('*', (req, res) => {

res.status(404).json({

success: false,

message: 'Route not found'

});

});

Alternatively, you can throw an error and let your centralized handler manage it:

app.use('*', (req, res, next) => {

const err = new Error('Route not found');

err.statusCode = 404;

next(err);

});

This approach ensures 404s are logged, formatted, and responded to in the same way as other errors.

Step 4: Handle Validation Errors with Custom Error Classes

Many applications use validation libraries like express-validator or Joi. These libraries often throw generic errors that dont distinguish between validation failures and other types of errors.

Create a custom error class to handle validation errors cleanly:

// ValidationError.js

class ValidationError extends Error {

constructor(message, details = []) {

super(message);

this.name = 'ValidationError';

this.statusCode = 400;

this.details = details;

}

}

module.exports = ValidationError;

Then, in your route:

const ValidationError = require('./ValidationError');

app.post('/users', async (req, res, next) => {

const { name, email } = req.body;

if (!name || name.trim().length === 0) {

return next(new ValidationError('Name is required'));

}

if (!email || !email.includes('@')) {

return next(new ValidationError('Valid email is required', [{ field: 'email', message: 'Invalid email format' }]));

}

// Proceed with user creation

const user = await User.create({ name, email });

res.status(201).json(user);

});

Update your error handler to recognize and format validation errors:

// Updated errorHandler.js

const errorHandler = (err, req, res, next) => {

console.error(err.stack);

let statusCode = err.statusCode || 500;

let message = err.message || 'Internal Server Error';

let details = err.details || [];

if (err.name === 'ValidationError') {

statusCode = 400;

message = 'Validation failed';

}

const errorResponse = {

success: false,

message,

...(process.env.NODE_ENV === 'development' && { stack: err.stack }),

...(details.length > 0 && { details })

};

res.status(statusCode).json(errorResponse);

};

module.exports = errorHandler;

This gives clients clear, structured feedback about what went wrong without exposing internal logic.

Step 5: Handle Database and External Service Errors

Database queries, API calls, and file system operations are common sources of runtime errors. These should be caught and translated into appropriate HTTP responses.

Example with MongoDB:

app.get('/posts/:id', async (req, res, next) => {

try {

const post = await Post.findById(req.params.id);

if (!post) {

throw new Error('Post not found'); // Will be caught by next()

}

res.json(post);

} catch (err) {

if (err.name === 'CastError') {

// Mongoose CastError: invalid ObjectId format

return next(new Error('Invalid post ID format'), 400);

}

next(err);

}

});

For external API calls:

app.get('/weather/:city', async (req, res, next) => {

try {

const response = await fetch(https://api.weather.com/${req.params.city});

if (!response.ok) {

throw new Error(External service returned ${response.status});

}

const data = await response.json();

res.json(data);

} catch (err) {

// Treat network failures as 502 Bad Gateway

if (err.name === 'FetchError') {

return next(new Error('Unable to reach weather service'), 502);

}

next(err);

}

});

Always map external errors to meaningful HTTP status codes: 502 for gateway failures, 504 for timeouts, 401/403 for authentication issues, etc.

Step 6: Handle Uncaught Exceptions and Rejected Promises

Even with comprehensive error handling, some errors may slip throughespecially unhandled promise rejections or synchronous errors outside middleware.

To prevent your server from crashing, add process-level error listeners:

// At the top of your main app file (after requiring modules)

process.on('uncaughtException', (err) => {

console.error('Uncaught Exception:', err);

// Do NOT call process.exit() here unless absolutely necessary

// Let the error handler respond if possible

});

process.on('unhandledRejection', (reason, promise) => {

console.error('Unhandled Rejection at:', promise, 'reason:', reason);

// Log and optionally shut down gracefully

});

However, these are last-resort measures. The goal is to catch all errors within middleware. Use these listeners only for logging and graceful shutdown in production.

For graceful shutdown on fatal errors:

process.on('uncaughtException', (err) => {

console.error('Fatal uncaught exception:', err);

server.close(() => {

console.log('Server closed due to uncaught exception');

process.exit(1);

});

});

process.on('unhandledRejection', (reason, promise) => {

console.error('Fatal unhandled rejection:', reason);

server.close(() => {

console.log('Server closed due to unhandled rejection');

process.exit(1);

});

});

This ensures your server shuts down cleanly rather than continuing in an unstable state.

Step 7: Integrate with Express Router for Modular Error Handling

Large applications often split routes into multiple routers. You can attach error-handling middleware to individual routers to isolate error logic by module.

// routes/users.js

const express = require('express');

const router = express.Router();

router.get('/', async (req, res, next) => {

try {

const users = await User.find();

res.json(users);

} catch (err) {

next(err);

}

});

// Error handler specific to this router

router.use((err, req, res, next) => {

console.error('User route error:', err);

res.status(500).json({ message: 'Something went wrong with user data' });

});

module.exports = router;

Then in your main app:

app.use('/api/users', usersRouter);

app.use(errorHandler); // Global fallback

This allows you to define route-specific error responses while retaining a global fallback for unexpected errors.

Best Practices

Never Expose Stack Traces in Production

Stack traces contain sensitive information: file paths, function names, variable names, and even environment variables. Exposing them in production responses is a serious security risk.

Always conditionally include stack traces only in development:

const errorResponse = {

success: false,

message: err.message,

...(process.env.NODE_ENV === 'development' && { stack: err.stack })

};

Use Consistent Error Response Format

Define a standard structure for all error responses. Clients should be able to predict the shape of errors:

{

"success": false,

"message": "Invalid email format",

"details": [

{ "field": "email", "message": "Email must contain @ symbol" }

]

}

This enables frontend and mobile clients to render user-friendly messages and highlight problematic fields.

Log Errors with Context

When logging errors, include contextual information: request ID, user ID (if authenticated), IP address, request method, and URL.

const errorHandler = (err, req, res, next) => {

const context = {

timestamp: new Date().toISOString(),

method: req.method,

url: req.url,

userAgent: req.get('User-Agent'),

ip: req.ip,

userId: req.user?.id || 'anonymous',

requestId: req.headers['x-request-id'] || 'none'

};

console.error('ERROR:', err.message, { context, stack: err.stack });

// ... rest of handler

};

This makes debugging significantly faster, especially in distributed systems.

Use HTTP Status Codes Correctly

Dont default everything to 500. Use appropriate codes:

  • 400 Bad Request (client sent malformed data)
  • 401 Unauthorized (missing or invalid credentials)
  • 403 Forbidden (authenticated but not authorized)
  • 404 Not Found
  • 429 Too Many Requests (rate limiting)
  • 500 Internal Server Error (unexpected server failure)
  • 502 Bad Gateway (downstream service failed)
  • 503 Service Unavailable (maintenance or overload)

Use err.statusCode to set the correct status code in your custom errors.

Validate Input Early

Use middleware like express-validator or Joi to validate request bodies, parameters, and query strings before they reach your business logic.

const { body } = require('express-validator');

app.post('/users',

body('email').isEmail().withMessage('Invalid email'),

body('password').isLength({ min: 8 }).withMessage('Password too short'),

async (req, res, next) => {

const errors = validationResult(req);

if (!errors.isEmpty()) {

return next(new ValidationError('Validation failed', errors.array()));

}

// Proceed

}

);

This reduces the need for repetitive validation logic in route handlers.

Dont Swallow Errors

Avoid this anti-pattern:

// BAD: Swallows errors silently

app.get('/data', async (req, res) => {

try {

const data = await fetchData();

res.json(data);

} catch (err) {

// No next(err), no response sent

}

});

This leaves the client hanging. Always respond or pass the error onward.

Use Environment-Specific Behavior

Adjust error handling based on environment:

  • Development: Show detailed errors, stack traces, and debug info.
  • Staging: Log everything, show minimal error details.
  • Production: Generic messages only, no stack traces, monitor for alerts.

Use environment variables to control behavior:

const isDev = process.env.NODE_ENV === 'development';

const isProd = process.env.NODE_ENV === 'production';

Implement Rate Limiting and Circuit Breakers

Errors often cluster during outages or attacks. 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);

For external dependencies, consider implementing circuit breakers using libraries like opossum to fail fast and avoid cascading failures.

Tools and Resources

Logging Libraries

  • Winston Highly configurable logging library with support for multiple transports (file, console, cloud).
  • Pino Extremely fast JSON logger optimized for production.
  • Morgan HTTP request logger; great for tracking incoming requests alongside errors.

Monitoring and Alerting

  • Sentry Real-time error tracking with stack traces, user context, and performance monitoring.
  • Datadog Comprehensive observability platform with log aggregation and alerting.
  • New Relic Application performance monitoring with error tracking.

Validation Libraries

  • express-validator Middleware for validating HTTP requests using Joi-style schemas.
  • Joi Powerful schema description language and validator for JavaScript objects.
  • Zod TypeScript-first schema validation library with excellent type inference.

Error Classification Tools

  • http-errors Creates standardized HTTP error objects with status codes.
  • class-transformer Useful for transforming class instances into plain objects with consistent error shapes.

Testing Tools

  • Jest Unit and integration testing framework. Test error responses with mock requests.
  • Supertest HTTP assertion library for Express apps. Ideal for testing error endpoints.

Example Test for Error Handling

const request = require('supertest');

const app = require('../app');

describe('Error Handling', () => {

it('returns 404 for non-existent route', async () => {

const response = await request(app).get('/api/nonexistent');

expect(response.status).toBe(404);

expect(response.body.success).toBe(false);

expect(response.body.message).toBe('Route not found');

});

it('returns 400 for validation error', async () => {

const response = await request(app)

.post('/api/users')

.send({ email: 'invalid-email' });

expect(response.status).toBe(400);

expect(response.body.details).toHaveLength(1);

});

});

Real Examples

Example 1: REST API with Proper Error Handling

Consider a user registration endpoint:

// routes/auth.js

const express = require('express');

const router = express.Router();

const User = require('../models/User');

const ValidationError = require('../errors/ValidationError');

router.post('/register', async (req, res, next) => {

const { email, password, name } = req.body;

// Validate input

if (!email || !password || !name) {

return next(new ValidationError('All fields are required'));

}

if (!email.includes('@')) {

return next(new ValidationError('Invalid email format', [{ field: 'email', message: 'Must be a valid email' }]));

}

if (password.length

return next(new ValidationError('Password must be at least 8 characters', [{ field: 'password', message: 'Too short' }]));

}

try {

const existingUser = await User.findOne({ email });

if (existingUser) {

return next(new ValidationError('Email already in use', [{ field: 'email', message: 'Already registered' }]));

}

const user = await User.create({ email, password, name });

res.status(201).json({ success: true, data: { id: user._id, email: user.email } });

} catch (err) {

if (err.name === 'MongoServerError' && err.code === 11000) {

return next(new ValidationError('Email already exists', [{ field: 'email', message: 'Duplicate entry' }]));

}

next(err);

}

});

module.exports = router;

Global error handler:

// middleware/errorHandler.js

const errorHandler = (err, req, res, next) => {

const context = {

method: req.method,

url: req.url,

ip: req.ip,

userAgent: req.get('User-Agent'),

timestamp: new Date().toISOString()

};

console.error('ERROR:', err.message, { context, stack: err.stack });

let statusCode = err.statusCode || 500;

let message = err.message || 'Internal Server Error';

let details = err.details || [];

if (err.name === 'ValidationError') {

statusCode = 400;

message = 'Validation failed';

}

if (err.name === 'MongoServerError') {

statusCode = 400;

message = 'Database error';

}

const response = {

success: false,

message,

...(process.env.NODE_ENV === 'development' && { stack: err.stack }),

...(details.length > 0 && { details })

};

res.status(statusCode).json(response);

};

module.exports = errorHandler;

Example 2: API Gateway with External Service Failures

Imagine an app that fetches weather data from a third-party service:

// routes/weather.js

const express = require('express');

const router = express.Router();

const axios = require('axios');

router.get('/current/:city', async (req, res, next) => {

const { city } = req.params;

try {

const response = await axios.get(https://api.weatherapi.com/v1/current.json, {

params: {

key: process.env.WEATHER_API_KEY,

q: city

},

timeout: 5000 // 5-second timeout

});

res.json(response.data);

} catch (err) {

if (err.code === 'ECONNABORTED') {

return next(new Error('Weather service timed out'), 504);

}

if (err.response?.status === 401) {

return next(new Error('Invalid weather API key'), 500);

}

if (err.response?.status === 404) {

return next(new Error('City not found'), 404);

}

next(new Error('Failed to fetch weather data'), 502);

}

});

module.exports = router;

This ensures that each type of failure returns the correct HTTP status and message, allowing clients to respond appropriately (e.g., retry, notify user, fallback to cached data).

Example 3: Handling File Upload Errors

File uploads often trigger errors due to size limits, unsupported types, or disk space issues:

const multer = require('multer');

const upload = multer({

dest: 'uploads/',

limits: { fileSize: 5 * 1024 * 1024 }, // 5MB

fileFilter: (req, file, cb) => {

if (file.mimetype.startsWith('image/')) {

cb(null, true);

} else {

cb(new Error('Only image files are allowed'), false);

}

}

});

app.post('/upload', upload.single('avatar'), (req, res, next) => {

// If multer encounters an error, it will pass it to next()

// So we don't need try/catch here

res.json({ url: /uploads/${req.file.filename} });

});

// Error handler catches multer errors

app.use((err, req, res, next) => {

if (err instanceof multer.MulterError) {

if (err.code === 'LIMIT_FILE_SIZE') {

return res.status(400).json({ message: 'File too large. Max 5MB.' });

}

}

if (err.message === 'Only image files are allowed') {

return res.status(400).json({ message: 'Invalid file type. Only images allowed.' });

}

next(err); // Pass other errors to global handler

});

FAQs

What happens if I dont use error-handling middleware in Express?

If you dont define custom error-handling middleware, Express will use its default handler, which sends a plain-text error message with a stack trace in development and a minimal message in production. This is insecure and unprofessional. Uncaught exceptions may also crash your Node.js process entirely.

Can I use try/catch with regular (non-async) middleware?

Yes, but its less common. Regular middleware doesnt return promises, so errors must be thrown synchronously. You can wrap synchronous code in try/catch and call next(err) to pass the error forward.

Why should I use custom error classes instead of plain Error objects?

Custom error classes allow you to identify error types programmatically (e.g., if (err instanceof ValidationError)), attach additional metadata (like validation details), and ensure consistent structure across your application.

How do I test error responses in Express?

Use testing libraries like Supertest to simulate HTTP requests and assert on response status codes and body content. Mock dependencies (like databases) to trigger specific errors and verify your error handler responds correctly.

Should I log every error, even if its a 404?

Yes. 404s can indicate broken links, scanning bots, or misconfigured clients. Logging them helps you identify API misuse, outdated documentation, or potential security probes.

Is it okay to use process.exit() in error handlers?

Only in extreme cases, such as uncaught exceptions that compromise application integrity. In most cases, let the error handler respond and allow the server to continue serving other requests. Use graceful shutdown patterns instead.

How do I handle errors in WebSocket connections with Express?

Express handles HTTP requests. For WebSockets (using libraries like Socket.IO), you must implement error handling within the WebSocket server logic. Use try/catch in event handlers and emit error events to clients.

Whats the difference between next(err) and res.status(500).send(err)?

next(err) passes the error to Expresss error-handling middleware chain, allowing centralized, consistent error formatting. res.status(500).send(err) sends a response immediately and bypasses error middleware, leading to inconsistent behavior and potential double-response errors.

Conclusion

Effective error handling 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, informative responses that help users and developers alike.

By following the patterns outlined in this guidecentralized error middleware, custom error classes, proper HTTP status codes, comprehensive logging, and environment-aware responsesyou create applications that are not only more robust but also easier to debug, maintain, and scale.

Remember: errors are inevitable. How you handle them defines the quality of your application. Invest time in error handling early, document your patterns, and test them rigorously. The effort you put into handling errors today will save countless hours of debugging and user complaints tomorrow.

Start small: implement a single error handler today. Then layer in validation, logging, and monitoring. Over time, your application will become more resilient, secure, and production-ready.