Error Handling in Express Backend Applications

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 good error handling system should:

Step 2: Devise a Plan

  1. Create middleware for catching unmatched routes (404 errors)
  2. Build middleware for handling Sequelize database errors
  3. Implement a general error formatter middleware
  4. Connect all middleware to the Express application
  5. 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:

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:

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:

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:


// 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:

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:

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:

  1. When an error occurs, you can pass it to the next function: next(err)
  2. This skips all remaining regular middleware
  3. Control jumps to the first error-handling middleware (functions with four parameters)
  4. Error handlers can modify the error or pass it along with next(err)
  5. 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:

Best Practices We've Applied

Our error handling system follows several best practices:

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:

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:


// 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:

API Design Patterns

Error handling in APIs typically follows these patterns:

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:

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:

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:

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.