How to Handle Errors in Express

How to Handle Errors in Express Express.js is one of the most widely used web frameworks for Node.js, prized for its minimalism, flexibility, and performance. However, like any robust backend system, it is vulnerable to runtime errors—whether from malformed requests, database failures, unhandled promises, or misconfigured middleware. Properly handling these errors is not just a best practice; it i

Nov 6, 2025 - 11:16
Nov 6, 2025 - 11:16
 6

How to Handle Errors in Express

Express.js is one of the most widely used web frameworks for Node.js, prized for its minimalism, flexibility, and performance. However, like any robust backend system, it is vulnerable to runtime errorswhether from malformed requests, database failures, unhandled promises, or misconfigured middleware. Properly handling these errors is not just a best practice; it is a necessity for building reliable, scalable, and user-friendly applications.

When errors are not handled correctly, users encounter cryptic 500 Internal Server Errors, sensitive stack traces are exposed to the public, and monitoring systems fail to capture critical issues. Worse, uncaught exceptions can crash your entire Node.js process, leading to downtime and lost revenue.

This comprehensive guide walks you through every aspect of error handling in Express.jsfrom basic middleware patterns to advanced logging, classification, and recovery strategies. Whether you're a beginner learning Express for the first time or a seasoned developer refining production systems, this tutorial will equip you with the knowledge to build resilient applications that handle failure gracefully.

Step-by-Step Guide

Understanding Express Error Handling Mechanisms

Express.js follows a specific middleware execution model. Middleware functions are executed sequentially, and each has access to the request (req), response (res), and the next middleware function (next).

When an error occurs, you can pass it to the next middleware by calling next(error). Express will skip all subsequent non-error middleware functions and look for an error-handling middlewaredefined as a function with four parameters: (err, req, res, next).

Without an error-handling middleware, Express will send a default error responseoften a plain text stack tracewhich is unacceptable in production.

Step 1: Use try-catch for Synchronous Code

Many errors in Express arise from synchronous operations, such as parsing JSON, accessing object properties, or file system operations. Always wrap potentially failing synchronous code in a try-catch block and pass the error to next().

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

try {

const user = users[req.params.id];

if (!user) throw new Error('User not found');

res.json(user);

} catch (err) {

next(err); // Pass error to error-handling middleware

}

});

This ensures that any thrown error is caught and routed to your centralized error handler instead of crashing the process.

Step 2: Handle Asynchronous Errors with Async/Await

Asynchronous code is the most common source of unhandled rejections in Express. Using async/await without proper error handling leads to silent failures.

There are two recommended approaches:

Approach A: Wrap in try-catch

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

try {

const posts = await Post.find().exec();

res.json(posts);

} catch (err) {

next(err);

}

});

Approach B: Use a Promise-based Helper (Recommended)

To avoid repetitive try-catch blocks, create a utility function that wraps async routes:

const asyncHandler = fn => (req, res, next) =>

Promise.resolve(fn(req, res, next)).catch(next);

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

const posts = await Post.find().exec();

res.json(posts);

}));

Now you can write clean, error-free async routes without wrapping every function in a try-catch.

Step 3: Create a Centralized Error-Handling Middleware

Define a middleware function with four parameters to catch all errors passed via next(err). This function must be registered after all other routes and middleware.

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

console.error(err.stack);

res.status(500).json({

message: 'Something went wrong!',

error: process.env.NODE_ENV === 'development' ? err : {}

});

});

Key points:

  • Always log the error for debugging.
  • Never expose stack traces or internal details in production.
  • Use environment variables to toggle verbosity.

Step 4: Classify Errors with Custom Error Types

Not all errors are the same. You should distinguish between:

  • Client errors (4xx): Invalid input, unauthorized access, not found
  • Server errors (5xx): Database failures, unhandled exceptions, timeouts

Create a custom error class to standardize error responses:

class AppError extends Error {

constructor(message, statusCode) {

super(message);

this.statusCode = statusCode;

this.status = ${statusCode}.startsWith('4') ? 'fail' : 'error';

this.isOperational = true; // Marks error as expected (not a bug)

Error.captureStackTrace(this, this.constructor);

}

}

// Usage in routes

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

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

if (!user) return next(new AppError('User not found', 404));

res.json(user);

}));

Update your error handler to respond appropriately:

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

err.statusCode = err.statusCode || 500;

err.status = err.status || 'error';

if (process.env.NODE_ENV === 'development') {

res.status(err.statusCode).json({

status: err.status,

error: err,

message: err.message,

stack: err.stack

});

} else {

// Production: Hide stack and internal details

let message = err.message;

if (err.name === 'CastError') message = 'Invalid ID format';

if (err.name === 'ValidationError') message = Object.values(err.errors).map(val => val.message).join(', ');

res.status(err.statusCode).json({

status: err.status,

message

});

}

});

Step 5: Handle Uncaught Exceptions and Rejections

Even with proper error handling, some errors escape your middlewarelike unhandled promise rejections or synchronous errors outside route handlers.

Use process-level event listeners to prevent crashes:

// Handle uncaught exceptions (synchronous)

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);

});

?? Note: uncaughtException should be used cautiously. Its better to fix the root cause than to rely on this as a safety net. Use it only to log and shut down cleanly.

Step 6: Integrate with Logging Services

Manual console logging is insufficient in production. Use structured logging libraries to capture errors with context:

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()

]

});

// In your error handler

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

logger.error({

message: err.message,

stack: err.stack,

url: req.url,

method: req.method,

ip: req.ip,

timestamp: new Date().toISOString()

});

// ... rest of error response

});

Step 7: Test Error Scenarios

Never assume your error handling works. Write tests for common failure cases:

describe('GET /user/:id', () => {

it('returns 404 if user not found', async () => {

const res = await request(app).get('/user/999');

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

expect(res.body.message).toBe('User not found');

});

it('returns 500 on database failure', async () => {

// Mock database to throw error

jest.spyOn(User, 'findById').mockImplementationOnce(() => {

throw new Error('Database timeout');

});

const res = await request(app).get('/user/123');

expect(res.status).toBe(500);

expect(res.body.message).toBe('Something went wrong!');

});

});

Best Practices

1. Always Use Error-Handling Middleware

Never rely on Expresss default error response. Always define at least one error-handling middleware at the end of your middleware stack.

2. Never Expose Sensitive Information

Stack traces, database schema details, file paths, and environment variables should never be sent to clients in production. Use environment flags to toggle verbose responses only in development.

3. Use HTTP Status Codes Correctly

Map errors to appropriate HTTP status codes:

  • 400 Bad Request: Invalid input (e.g., missing fields, malformed JSON)
  • 401 Unauthorized: Authentication required
  • 403 Forbidden: Authentication passed, but insufficient permissions
  • 404 Not Found: Resource does not exist
  • 429 Too Many Requests: Rate limiting exceeded
  • 500 Internal Server Error: Unexpected server failure
  • 502 Bad Gateway: Downstream service failed
  • 503 Service Unavailable: Server temporarily overloaded

4. Avoid Silent Failures

Always log errorseven if you return a generic message to the client. Silent failures make debugging impossible.

5. Use Custom Error Classes for Consistency

Custom error classes make it easier to identify, filter, and respond to different types of errors. They also improve code readability and testability.

6. Validate Input Early

Use middleware like express-validator to validate request data before it reaches your business logic. This reduces the chance of unexpected errors downstream.

7. Implement Circuit Breakers for External Services

If your app depends on third-party APIs or databases, use libraries like opossum to implement circuit breaker patterns. This prevents cascading failures when external services are down.

8. Monitor and Alert

Integrate with monitoring tools (e.g., Sentry, Datadog, New Relic) to receive real-time alerts when errors occur. Track error rates, frequency, and trends over time.

9. Graceful Degradation

Design systems to degrade gracefully. For example, if a recommendation engine fails, return cached data or default content instead of a 500 error.

10. Document Error Responses

Include error response formats in your API documentation. Developers consuming your API need to know what to expect when things go wrong.

Tools and Resources

1. winston Logging Library

Winston is the most popular logging library for Node.js. It supports multiple transports (file, console, HTTP), custom formats, and structured JSON logging.

2. morgan HTTP Request Logger

Morgan logs HTTP requests and responses. Combine it with your error logger to correlate errors with specific requests.

const morgan = require('morgan');

app.use(morgan('combined'));

3. express-validator Request Validation

express-validator provides middleware to validate and sanitize HTTP request data using chaining syntax.

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

app.post('/user',

body('email').isEmail(),

body('name').notEmpty(),

asyncHandler(async (req, res) => {

const errors = validationResult(req);

if (!errors.isEmpty()) {

return next(new AppError('Validation failed', 400));

}

// Proceed

})

);

4. Sentry Error Monitoring

Sentry automatically captures exceptions, stack traces, and user context. It groups similar errors, tracks release versions, and sends alerts via email or Slack.

5. New Relic Performance Monitoring

New Relic provides deep insights into application performance, including slow queries, external service latency, and error rates.

6. opencensus / opentelemetry Distributed Tracing

For microservices, use OpenTelemetry to trace requests across services and pinpoint where failures occur.

7. nodemon Development Auto-restart

While not an error-handling tool, nodemon automatically restarts your server on code changes, helping you catch errors faster during development.

8. Joi Schema Validation

For complex validation logic, Joi offers powerful schema validation with detailed error messages.

9. helmet Security Middleware

Helmet helps secure Express apps by setting various HTTP headers that prevent common attacks (XSS, clickjacking, etc.).

10. dotenv Environment Management

Dotenv loads environment variables from a .env file. Essential for managing different error verbosity levels across environments.

Real Examples

Example 1: API with Authentication and Validation

Imagine a user registration endpoint that requires email, password, and name. It also checks for duplicate emails and handles database failures.

const express = require('express');

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

const AppError = require('./utils/AppError');

const asyncHandler = require('./utils/asyncHandler');

const app = express();

app.use(express.json());

// Validation middleware

const validateUser = [

body('email').isEmail().withMessage('Valid email required'),

body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),

body('name').notEmpty().withMessage('Name is required')

];

app.post('/register', validateUser, asyncHandler(async (req, res, next) => {

const errors = validationResult(req);

if (!errors.isEmpty()) {

return next(new AppError('Validation failed', 400));

}

try {

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

if (existingUser) {

return next(new AppError('Email already in use', 409));

}

const user = await User.create(req.body);

res.status(201).json({

status: 'success',

data: { user: user.select('-password') }

});

} catch (err) {

if (err.code === 11000) {

return next(new AppError('Email already exists', 409));

}

next(new AppError('Database error', 500));

}

}));

// Error handler

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

console.error(err.stack);

if (err instanceof AppError) {

return res.status(err.statusCode).json({

status: err.status,

message: err.message

});

}

if (process.env.NODE_ENV === 'development') {

res.status(500).json({

status: 'error',

message: err.message,

stack: err.stack

});

} else {

res.status(500).json({

status: 'error',

message: 'Something went wrong!'

});

}

});

module.exports = app;

Example 2: Rate-Limited API with Circuit Breaker

Protect your API from abuse using rate limiting and circuit breaking.

const express = require('express');

const rateLimit = require('express-rate-limit');

const CircuitBreaker = require('opossum');

const app = express();

// Rate limiting: 100 requests per 15 minutes per IP

const limiter = rateLimit({

windowMs: 15 * 60 * 1000,

max: 100,

message: { message: 'Too many requests, please try again later.' }

});

app.use(limiter);

// Circuit breaker for external payment API

const paymentBreaker = new CircuitBreaker(async () => {

const response = await fetch('https://api.paymentgateway.com/charge', {

method: 'POST',

body: JSON.stringify(req.body)

});

if (!response.ok) throw new Error('Payment failed');

return response.json();

}, {

timeout: 5000,

errorThresholdPercentage: 50,

resetTimeout: 30000

});

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

try {

const result = await paymentBreaker.fire(req.body);

res.json(result);

} catch (err) {

if (paymentBreaker.stats.failures > 10) {

return next(new AppError('Payment service is temporarily unavailable', 503));

}

next(new AppError('Payment processing failed', 500));

}

}));

// Error handler (same as above)

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

// ... error response logic

});

Example 3: Error Logging with Winston and Cloud Storage

Log errors to a file and upload them to AWS S3 for centralized monitoring.

const winston = require('winston');

const { S3 } = require('@aws-sdk/client-s3');

const logger = winston.createLogger({

level: 'error',

format: winston.format.json(),

transports: [

new winston.transports.File({ filename: 'logs/error.log' })

]

});

// Upload log file to S3 every hour

setInterval(async () => {

const s3 = new S3({ region: 'us-east-1' });

const file = fs.readFileSync('logs/error.log', 'utf8');

await s3.putObject({

Bucket: 'my-app-logs',

Key: errors/${new Date().toISOString().slice(0,10)}.log,

Body: file

});

fs.writeFileSync('logs/error.log', ''); // Clear file

}, 3600000);

FAQs

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

If you dont define an error-handling middleware, Express will send a default response with a stack trace when an error occurs. In production, this exposes internal server details, which is a security risk. Additionally, uncaught exceptions may crash your Node.js process entirely.

Q2: Can I use try-catch with async/await without next()?

No. If you use try-catch with async/await but dont call next(err), the error is caught locally and the request hangs indefinitely because no response is sent. Always pass the error to next() so Express can route it to your error handler.

Q3: Should I use process.on('uncaughtException') to prevent crashes?

Its not recommended as a primary strategy. Use it only to log the error and shut down the process cleanly. The goal is to fix the root cause, not to keep a faulty server running. Relying on uncaughtException can mask bugs and lead to unpredictable behavior.

Q4: How do I handle validation errors in Express?

Use express-validator or Joi to validate request data. If validation fails, create a 400 AppError and pass it to next(). Your centralized error handler can then format a clean, user-friendly response.

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

Custom error classes allow you to:

  • Set custom status codes
  • Identify error types programmatically (e.g., if (err instanceof AppError))
  • Include additional metadata (e.g., error code, category)
  • Improve testability and maintainability

Q6: How do I test error responses in Express?

Use testing libraries like supertest or node-fetch to simulate HTTP requests and assert the status code and response body. Mock dependencies (like databases) to trigger specific error conditions.

Q7: Whats the difference between 4xx and 5xx errors in Express?

4xx errors indicate client-side issues (e.g., invalid input, unauthorized access). 5xx errors indicate server-side failures (e.g., database crashes, unhandled exceptions). Clients should not retry 5xx errors without intervention; they should be monitored and fixed by developers.

Q8: Can I handle errors globally across multiple Express apps?

Yes. Extract your error-handling middleware and custom error classes into a shared npm package. Import and use it across microservices or monorepos for consistency.

Q9: How do I handle errors in WebSocket or Socket.IO with Express?

WebSocket and Socket.IO have separate error handling mechanisms. Use their built-in on('error') listeners and wrap socket event handlers in try-catch blocks. Do not rely on Express middleware for socket errors.

Q10: Is it safe to log errors to the console in production?

Its acceptable if youre using a structured logging system like Winston that writes to files or remote services. Avoid logging sensitive data (passwords, tokens, PII) even in logs. Always sanitize logs before storing or transmitting them.

Conclusion

Error handling in Express.js is not an afterthoughtit is a foundational component of production-grade applications. A well-structured error-handling strategy improves user experience, enhances system reliability, simplifies debugging, and protects your application from security risks.

In this guide, youve learned how to:

  • Use try-catch and asyncHandler to manage synchronous and asynchronous errors
  • Create custom error classes for consistent, meaningful responses
  • Build a centralized error-handling middleware that adapts to environment settings
  • Prevent process crashes with uncaught exception listeners
  • Integrate with logging and monitoring tools like Winston and Sentry
  • Apply best practices for HTTP status codes, input validation, and graceful degradation
  • Test error scenarios to ensure your handlers work as expected

Remember: errors are inevitable. But how you respond to them defines the quality of your application. By implementing the patterns and tools outlined here, you transform error handling from a reactive chore into a proactive, strategic advantage.

Start small: add one error-handling middleware today. Then gradually layer in validation, logging, and monitoring. Over time, your Express applications will become more resilient, maintainable, and trustworthyready to handle the unpredictable nature of real-world usage.