Error Handling in Express.js

Learn how to handle errors gracefully in your Express.js application using middleware and error-handling routes.


Mastering Express.js: Error Handling

Learn how to handle errors gracefully in your Express.js application using middleware and error-handling routes.

Error Handling in Express.js

Error handling is crucial for building robust and reliable Express.js applications. Unhandled errors can crash your server, expose sensitive information, or lead to unexpected behavior. Express.js provides mechanisms to catch and handle errors at different stages of the request lifecycle, allowing you to respond gracefully and provide helpful feedback to users and log errors for debugging.

Types of Errors in Express.js

Express.js applications can encounter various types of errors, including:

  • Synchronous Errors: Errors that occur immediately within a synchronous code block (e.g., a typo in a variable name, an attempt to access an undefined property).
  • Asynchronous Errors: Errors that occur during asynchronous operations (e.g., database queries, API calls, file system operations). These often involve callbacks, Promises, or async/await.
  • HTTP Errors: Errors related to HTTP requests and responses (e.g., 404 Not Found, 500 Internal Server Error).
  • Validation Errors: Errors that occur when data doesn't meet the expected format or constraints (e.g., invalid email address, missing required fields).

Error Handling Techniques

Here are the primary methods for handling errors in Express.js:

1. Try-Catch Blocks

For synchronous errors, try...catch blocks provide a simple way to catch and handle exceptions. However, they don't work directly with asynchronous operations.

 app.get('/sync-error', (req, res) => {
      try {
        // Code that might throw an error
        const result = undefinedVariable.toUpperCase(); // This will cause a ReferenceError
        res.send(result);
      } catch (error) {
        console.error(error);
        res.status(500).send('An error occurred: ' + error.message);
      }
    }); 

2. Asynchronous Error Handling (Promises & Async/Await)

When using Promises or async/await, you can use .catch() or try...catch to handle asynchronous errors.

 app.get('/async-error', async (req, res, next) => {
      try {
        const data = await someAsyncOperation(); // Assume this function returns a Promise
        res.json(data);
      } catch (error) {
        // Pass the error to the error handling middleware
        next(error);
      }
    });

    async function someAsyncOperation() {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
              reject(new Error("Async operation failed!"));
            }, 500);
        });
    } 

3. Error-Handling Middleware

Express.js provides a special type of middleware designed specifically for handling errors. Error-handling middleware functions have four arguments: (err, req, res, next). The presence of four arguments tells Express.js that this is an error-handling middleware. You define them after all other middleware and routes.

 // Error-handling middleware
    app.use((err, req, res, next) => {
      console.error(err.stack); // Log the error stack trace
      res.status(500).send('Something broke!'); // Generic error message
    }); 

Important: The next function in error-handling middleware allows you to pass the error to the next error handler in the chain, if one exists. This is useful for creating layered error handling.

4. Custom Error Classes

Creating custom error classes can help you categorize and handle specific types of errors more effectively. This allows you to provide more informative error messages and tailor your error handling logic.

 class CustomError extends Error {
      constructor(message, statusCode) {
        super(message);
        this.statusCode = statusCode;
        this.name = this.constructor.name; // Ensure the name is correct
        Error.captureStackTrace(this, this.constructor); // Optional: Preserve stack trace
      }
    }

    app.get('/custom-error', (req, res, next) => {
      try {
        throw new CustomError('Resource not found', 404);
      } catch (error) {
        next(error); // Pass the error to the error handler
      }
    });

    app.use((err, req, res, next) => {
      if (err instanceof CustomError) {
        res.status(err.statusCode).send({ error: err.message });
      } else {
        // Handle other types of errors
        console.error(err.stack);
        res.status(500).send('Internal Server Error');
      }
    }); 

Best Practices for Error Handling

  • Log Errors: Use a logging library (e.g., Winston, Morgan) to log errors to a file or database. Include relevant information like the error message, stack trace, request parameters, and user information.
  • Don't Expose Sensitive Information: Avoid exposing sensitive details about your application's internal workings in error messages. Return generic error messages to the user and log detailed information internally.
  • Use Meaningful Error Messages: Provide clear and helpful error messages to users whenever possible. For example, if a user tries to create an account with an invalid email address, provide a specific error message indicating that the email address is invalid.
  • Handle Asynchronous Errors: Pay special attention to handling errors in asynchronous operations using Promises, async/await, and error-handling middleware.
  • Test Your Error Handling: Write unit tests to ensure that your error handling logic works correctly. Simulate different error scenarios and verify that your application handles them gracefully.
  • Centralized Error Handling: Using error-handling middleware allows you to centralize your error handling logic, making your code more maintainable and easier to debug.
  • Consider using a library: Libraries like express-async-errors can help simplify asynchronous error handling by automatically catching errors in async route handlers.

Example of Comprehensive Error Handling

This example combines custom errors, logging, and error-handling middleware to demonstrate a more robust error handling strategy.

 const express = require('express');
  const app = express();
  const logger = require('morgan'); // For logging HTTP requests
  const fs = require('fs'); // For file operations

  // Custom Error Class
  class ApiError extends Error {
      constructor(statusCode, message, isOperational = true, stack = '') {
          super(message);
          this.statusCode = statusCode;
          this.isOperational = isOperational; // Helps differentiate between programmer errors and operational errors.
          if (stack) {
              this.stack = stack;
          } else {
              Error.captureStackTrace(this, this.constructor);
          }
      }
  }

  // Logger setup (using Morgan to log to a file)
  const logStream = fs.createWriteStream('access.log', { flags: 'a' });
  app.use(logger('combined', { stream: logStream }));

  // Middleware for converting non-ApiError errors to ApiError
  const errorHandler = (err, req, res, next) => {
    let error = err;
    if (!(err instanceof ApiError)) {
        const statusCode = err.statusCode || 500;
        const message = err.message || 'Internal Server Error';
        error = new ApiError(statusCode, message, false, err.stack); // isOperational defaults to true. set to false for unhandled exceptions.
    }
    // Call the actual error handling function
    handleError(error, res);
  };

  const handleError = (err, res) => {
      // Log the error
      console.error(err); // Log to console for debugging
      fs.appendFileSync('errors.log', `${new Date().toISOString()} - ${err.stack}\n`); // Log detailed error info to file

      // Send response to client
      res.status(err.statusCode || 500).json({
          status: 'error',
          statusCode: err.statusCode || 500,
          message: err.message || 'Internal Server Error',
          stack: process.env.NODE_ENV === 'development' ? err.stack : undefined // Only send stack in development
      });
  };

  // Simulate an Operational Error
  app.get('/operational', (req, res, next) => {
      next(new ApiError(400, 'Bad Request: Invalid input')); // Pass to our error handler
  });

  // Simulate a Programmer Error (uncaught exception)
  app.get('/programmer', (req, res) => {
      throw new Error('Simulated programmer error'); // This will be caught by the global uncaught exception handler if one is in place
  });


  // Routes
  app.get('/', (req, res) => {
      res.send('Hello World!');
  });


  // Global error handler - Must be defined LAST
  app.use(errorHandler);

  // Uncaught Exception Handler (VERY IMPORTANT) - Only for synchronous exceptions
  process.on('uncaughtException', err => {
      console.error('UNCAUGHT EXCEPTION!!!  shutting down...');
      console.error(err.name, err.message);
      fs.appendFileSync('fatal_errors.log', `${new Date().toISOString()} - ${err.stack}\n`); // Log detailed error info to file

      //  Gracefully shutdown server. In a real application, you might also attempt to clean up resources
      process.exit(1); // Exit immediately.  In prod use a server.close and timeout before exiting.
  });

  // Unhandled Rejection Handler - For unhandled promise rejections
  process.on('unhandledRejection', err => {
      console.error('UNHANDLED REJECTION!!!  shutting down...');
      console.error(err.name, err.message);
      fs.appendFileSync('fatal_errors.log', `${new Date().toISOString()} - ${err.stack}\n`); // Log detailed error info to file
      process.exit(1); // Exit immediately
  });


  const port = 3000;
  const server = app.listen(port, () => {
      console.log(\`App running on port ${port}\`);
  }); 

Key improvements in this comprehensive example:

  • Custom ApiError class: Differentiates between operational (expected) and programming (unexpected) errors.
  • Logging with Morgan and file storage: Logs HTTP requests (Morgan) and detailed error information to files.
  • Comprehensive error handling: Uses a centralized error handler middleware.
  • Error responses: Sends consistent JSON error responses to the client, including status code, message, and stack trace (in development mode).
  • Uncaught Exception and Unhandled Rejection handling: Gracefully handles uncaught exceptions and unhandled promise rejections, preventing the application from crashing. The server *should* be restarted by a process monitor (like pm2) in production.
  • Shutdown strategy: Logs fatal errors and immediately exits the process after an uncaught exception or unhandled rejection. In a real application, a more graceful shutdown with a timeout and resource cleanup should be used.