Asynchronous Error Handling in Express.js

Mastering techniques for handling errors in asynchronous Node.js code

The Challenge of Asynchronous Errors

Asynchronous programming is fundamental to Node.js and Express, but it introduces unique challenges for error handling. When errors occur in asynchronous code, they don't follow the same flow as synchronous errors, making them harder to catch and manage properly.

"Asynchronous errors are like ghosts in your application: they appear when you least expect them, and traditional error-catching mechanisms won't help you find them."

Why Asynchronous Error Handling Is Different

flowchart TB A[Client Request] --> B[Express Route Handler] B --> C[Synchronous Code] B --> D[Asynchronous Operation] C -->|Error| E[Error Middleware] D -->|Error| F["?? (Uncaught)"] E --> G[Error Response] F --> H[Crash/Unhandled Rejection]

The Problem with Asynchronous Code in Express

To understand why asynchronous error handling is challenging, let's examine the typical asynchronous patterns in Express applications.

Callbacks

Traditional Node.js callback pattern:

app.get('/users/:id', (req, res) => {
  // Asynchronous operation with callback
  database.getUser(req.params.id, (err, user) => {
    // Error in callback won't be caught by Express error handler
    if (err) {
      // Need manual error handling
      console.error(err);
      return res.status(500).json({ error: 'Database error' });
    }
    
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    
    res.json(user);
  });
});

Promises

Using promises without proper error handling:

app.get('/users/:id', (req, res) => {
  // Promise-based asynchronous operation
  database.getUserPromise(req.params.id)
    .then(user => {
      if (!user) {
        return res.status(404).json({ error: 'User not found' });
      }
      res.json(user);
    })
    .catch(err => {
      // Need to handle errors manually
      console.error(err);
      res.status(500).json({ error: 'Database error' });
    });
});

Async/Await

Using async/await without catching errors:

app.get('/users/:id', async (req, res) => {
  // Async/await without try/catch
  const user = await database.getUserAsync(req.params.id);
  
  // If getUserAsync throws, the error won't be caught by Express
  // and will cause an unhandled promise rejection
  
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  
  res.json(user);
});

Why Express Doesn't Catch Asynchronous Errors

Express was designed before promises and async/await became standard in JavaScript. Its error handling middleware system works with the traditional synchronous middleware pattern:

function middleware(req, res, next) {
  // If an error is thrown synchronously here, Express will catch it
  throw new Error('Sync error'); // This will be caught
  
  // But if an error happens in an async callback,
  // it occurs after this function has returned
  setTimeout(() => {
    throw new Error('Async error'); // This won't be caught by Express
  }, 100);
  
  // Similarly with promises
  Promise.resolve().then(() => {
    throw new Error('Promise error'); // This won't be caught by Express
  });
}

This behavior creates a significant gap in Express's error handling capabilities when dealing with modern asynchronous JavaScript.

Strategies for Handling Callback Errors

Even though promises and async/await are now preferred, many Node.js libraries still use callbacks. Here's how to handle errors properly with callbacks.

Pattern 1: Explicit Error Handling

Explicitly check for errors in each callback:

app.get('/users/:id', (req, res, next) => {
  database.getUser(req.params.id, (err, user) => {
    if (err) {
      // Pass the error to Express error handler
      return next(err);
    }
    
    if (!user) {
      // Create a custom error
      const error = new Error('User not found');
      error.statusCode = 404;
      return next(error);
    }
    
    res.json(user);
  });
});

Pattern 2: Error-First Callback Wrapper

Create a utility to handle error-first callbacks:

// utils/callbackHandler.js
function handleCallback(req, res, next) {
  return function(err, data) {
    if (err) {
      return next(err);
    }
    return data;
  };
}

// Usage
app.get('/users/:id', (req, res, next) => {
  database.getUser(
    req.params.id, 
    handleCallback(req, res, next, (user) => {
      if (!user) {
        const error = new Error('User not found');
        error.statusCode = 404;
        return next(error);
      }
      
      res.json(user);
    })
  );
});

Pattern 3: Promisify Callbacks

Convert callback-based functions to promises:

// utils/promisify.js
const { promisify } = require('util');

// Promisify specific function
const getUserPromise = promisify(database.getUser);

// Or promisify all methods in an object
const database = {
  getUser: promisify(originalDatabase.getUser),
  createUser: promisify(originalDatabase.createUser),
  updateUser: promisify(originalDatabase.updateUser),
  deleteUser: promisify(originalDatabase.deleteUser)
};

// Usage
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await getUserPromise(req.params.id);
    
    if (!user) {
      const error = new Error('User not found');
      error.statusCode = 404;
      return next(error);
    }
    
    res.json(user);
  } catch (err) {
    next(err);
  }
});

Node.js util.promisify

Node.js provides the util.promisify function to convert callback-based functions to promise-based ones. It works with any function that follows the Node.js callback style, where:

  • The function's last argument is a callback
  • The callback takes an error as its first argument and result as the second

This is a powerful way to modernize legacy code and make error handling more consistent.

Handling Promise-Based Errors

Promises provide a cleaner way to handle asynchronous errors through the .catch() method, but they still need to be integrated properly with Express.

Pattern 1: Manual Promise Handling

Explicit .catch() to pass errors to Express:

app.get('/users/:id', (req, res, next) => {
  database.getUserPromise(req.params.id)
    .then(user => {
      if (!user) {
        const error = new Error('User not found');
        error.statusCode = 404;
        throw error; // This will be caught by the .catch() below
      }
      
      res.json(user);
    })
    .catch(err => {
      // Pass to Express error handler
      next(err);
    });
});

Pattern 2: Promise Middleware Wrapper

Create a utility to handle promise-returning route handlers:

// utils/promiseHandler.js
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next))
      .catch(next); // Pass any error to Express error middleware
  };
}

// Usage
app.get('/users/:id', asyncHandler((req, res) => {
  return database.getUserPromise(req.params.id)
    .then(user => {
      if (!user) {
        const error = new Error('User not found');
        error.statusCode = 404;
        throw error;
      }
      
      res.json(user);
    });
}));

Pattern 3: Promise-Aware Error Middleware

Enhance Express's error handling to deal with unhandled promise rejections:

// Enhanced promise error handler
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // Optionally terminate the process
  // process.exit(1);
});

// Custom middleware to catch promise errors in routes
app.use((err, req, res, next) => {
  // Handle the error
  console.error(err);
  
  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';
  
  res.status(statusCode).json({
    error: {
      message,
      status: statusCode
    }
  });
});

Real-World Example: Database Query with Error Handling

Consider a user profile API that needs to fetch related data from multiple database tables:

app.get('/users/:id/profile', asyncHandler(async (req, res) => {
  const userId = req.params.id;
  
  // Get user data
  const user = await db.getUserById(userId);
  if (!user) {
    const error = new Error('User not found');
    error.statusCode = 404;
    throw error;
  }
  
  // Fetch related data in parallel with Promise.all
  try {
    const [posts, comments, followers] = await Promise.all([
      db.getPostsByUser(userId),
      db.getCommentsByUser(userId),
      db.getFollowersByUser(userId)
    ]);
    
    // Combine all data
    res.json({
      user,
      activity: {
        posts,
        comments
      },
      social: {
        followers
      }
    });
  } catch (error) {
    // More specific error based on what failed
    const enhancedError = new Error('Failed to fetch profile data');
    enhancedError.statusCode = 500;
    enhancedError.originalError = error;
    throw enhancedError;
  }
}));

This example shows how to handle errors at different stages of a complex operation and provide meaningful error information.

Handling Async/Await Errors

Async/await provides the most readable way to work with asynchronous code, but it requires specific error handling patterns for Express.

Pattern 1: Try/Catch in Each Route

Wrap async route handlers in try/catch blocks:

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await database.getUserAsync(req.params.id);
    
    if (!user) {
      const error = new Error('User not found');
      error.statusCode = 404;
      return next(error);
    }
    
    res.json(user);
  } catch (err) {
    next(err); // Pass to Express error handler
  }
});

Pattern 2: Async Handler Wrapper

Create a utility to catch errors in async functions:

// utils/catchAsync.js
const catchAsync = fn => {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
};

// Usage
app.get('/users/:id', catchAsync(async (req, res, next) => {
  const user = await database.getUserAsync(req.params.id);
  
  if (!user) {
    const error = new Error('User not found');
    error.statusCode = 404;
    return next(error);
  }
  
  res.json(user);
}));

Pattern 3: Express Async Errors Package

Use a library to patch Express to handle async errors automatically:

// Install: npm install express-async-errors

// Add at the very beginning of your app
require('express-async-errors');
const express = require('express');
const app = express();

// Now async errors will be automatically caught
app.get('/users/:id', async (req, res) => {
  const user = await database.getUserAsync(req.params.id);
  
  if (!user) {
    const error = new Error('User not found');
    error.statusCode = 404;
    throw error; // This will be caught and passed to error middleware
  }
  
  res.json(user);
});

Pattern 4: Class-Based Route Handlers

Organize routes with class methods and a wrapper:

// controllers/UserController.js
class UserController {
  // Get user by ID
  async getUser(req, res) {
    const user = await database.getUserAsync(req.params.id);
    
    if (!user) {
      const error = new Error('User not found');
      error.statusCode = 404;
      throw error;
    }
    
    res.json(user);
  }
  
  // Create user
  async createUser(req, res) {
    const { name, email, password } = req.body;
    
    // Validation
    if (!name || !email || !password) {
      const error = new Error('Missing required fields');
      error.statusCode = 400;
      throw error;
    }
    
    const user = await database.createUserAsync({ name, email, password });
    res.status(201).json(user);
  }
}

// Create a wrapper for controller methods
const wrapAsync = (controller, method) => {
  return (req, res, next) => {
    controller[method](req, res, next).catch(next);
  };
};

// routes/userRoutes.js
const controller = new UserController();

router.get('/users/:id', wrapAsync(controller, 'getUser'));
router.post('/users', wrapAsync(controller, 'createUser'));
flowchart TB A[Async/Await Error Handling] --> B[Try/Catch] A --> C[Async Handler Wrapper] A --> D[express-async-errors] A --> E[Class-Based Controllers] B --> F[Explicit handling in each route] C --> G[Utility function wraps handlers] D --> H[Automatic patching of Express] E --> I[Object-oriented approach with wrappers]

Creating a Robust catchAsync Utility

Let's build a more advanced version of the catchAsync utility function that can handle different types of handlers.

Enhanced catchAsync Implementation

// utils/catchAsync.js
/**
 * Wraps an async function to automatically catch errors and pass them to Express error middleware
 * @param {Function} fn - The async route handler function
 * @param {Object} options - Options for error handling
 * @param {boolean} options.handleResponse - Whether to handle the response to avoid headers already sent errors
 * @returns {Function} Express middleware function
 */
const catchAsync = (fn, options = {}) => {
  return (req, res, next) => {
    // Set default options
    const opts = {
      handleResponse: true,
      ...options
    };
    
    // Track if response has been sent
    let responseSent = false;
    
    // Save original res.json/send/end methods
    const originalJson = res.json;
    const originalSend = res.send;
    const originalEnd = res.end;
    
    if (opts.handleResponse) {
      // Override response methods to track if response has been sent
      res.json = function(body) {
        responseSent = true;
        return originalJson.call(this, body);
      };
      
      res.send = function(body) {
        responseSent = true;
        return originalSend.call(this, body);
      };
      
      res.end = function(data) {
        responseSent = true;
        return originalEnd.call(this, data);
      };
    }
    
    // Execute the async function
    Promise.resolve(fn(req, res, next))
      .catch(err => {
        // Restore original methods
        if (opts.handleResponse) {
          res.json = originalJson;
          res.send = originalSend;
          res.end = originalEnd;
        }
        
        // Only pass error to next if response hasn't been sent
        if (!responseSent) {
          next(err);
        } else {
          console.error('Error occurred after response was sent:', err);
        }
      });
  };
};

module.exports = catchAsync;

Usage with Different Patterns

// Basic usage
app.get('/users/:id', catchAsync(async (req, res) => {
  const user = await database.getUserAsync(req.params.id);
  
  if (!user) {
    const error = new Error('User not found');
    error.statusCode = 404;
    throw error;
  }
  
  res.json(user);
}));

// With custom error handler
app.get('/products/:id', catchAsync(async (req, res) => {
  const product = await database.getProductAsync(req.params.id);
  
  if (!product) {
    // Custom error handling
    return res.status(404).json({ error: 'Product not found' });
  }
  
  res.json(product);
}, { handleResponse: true }));

// With middleware that might respond
app.post('/auth/login', catchAsync(async (req, res, next) => {
  const { email, password } = req.body;
  
  // Another middleware might handle the response
  if (!email || !password) {
    return next(new Error('Missing credentials'));
  }
  
  const user = await authService.authenticate(email, password);
  res.json({ token: generateToken(user) });
}));

Avoiding "Headers Already Sent" Errors

One common issue with asynchronous error handling is the "Cannot set headers after they are sent to the client" error. This happens when:

  1. Your route handler sends a response
  2. Then an asynchronous operation throws an error
  3. The error handler tries to send another error response

The enhanced catchAsync utility above tracks whether a response has been sent and avoids passing errors to next() if the response has already been sent, preventing this issue.

Advanced Async Error Handling Patterns

Beyond the basics, there are several advanced patterns for handling asynchronous errors in Express applications.

Pattern 1: Router-Level Async Handling

Apply async error handling at the router level:

// routes/asyncRouter.js
const express = require('express');
const catchAsync = require('../utils/catchAsync');

/**
 * Creates an Express router with automatic async error handling
 * @returns {Router} Express router with async error handling
 */
function createAsyncRouter() {
  const router = express.Router();
  const methods = ['get', 'post', 'put', 'delete', 'patch'];
  
  // Override router methods to wrap handlers in catchAsync
  methods.forEach(method => {
    const originalMethod = router[method];
    
    router[method] = function(path, ...handlers) {
      const wrappedHandlers = handlers.map(handler => {
        // Skip middleware with 4 parameters (error handlers)
        if (handler.length === 4) {
          return handler;
        }
        
        // Wrap async handlers
        return catchAsync(handler);
      });
      
      // Call original method with wrapped handlers
      return originalMethod.call(this, path, ...wrappedHandlers);
    };
  });
  
  return router;
}

// Usage
const router = createAsyncRouter();

router.get('/users/:id', async (req, res) => {
  // No need for try/catch or catchAsync here
  const user = await database.getUserAsync(req.params.id);
  
  if (!user) {
    const error = new Error('User not found');
    error.statusCode = 404;
    throw error;
  }
  
  res.json(user);
});

module.exports = router;

Pattern 2: Declarative Route Definitions

Define routes with an object-based approach:

// routes/declarativeRoutes.js
const catchAsync = require('../utils/catchAsync');

/**
 * Creates route handlers from a declarative object definition
 * @param {Object} routeDefinitions - Object with route definitions
 * @returns {Object} Object with initialized route handlers
 */
function createRoutes(routeDefinitions) {
  const routes = {};
  
  Object.entries(routeDefinitions).forEach(([name, definition]) => {
    routes[name] = catchAsync(definition.handler);
  });
  
  return routes;
}

// Route definitions
const userRoutes = createRoutes({
  getUser: {
    method: 'GET',
    path: '/users/:id',
    handler: async (req, res) => {
      const user = await database.getUserAsync(req.params.id);
      
      if (!user) {
        const error = new Error('User not found');
        error.statusCode = 404;
        throw error;
      }
      
      res.json(user);
    }
  },
  createUser: {
    method: 'POST',
    path: '/users',
    handler: async (req, res) => {
      const user = await database.createUserAsync(req.body);
      res.status(201).json(user);
    }
  }
});

// Apply routes to the router
function applyRoutes(router, routeDefinitions, handlers) {
  Object.entries(routeDefinitions).forEach(([name, definition]) => {
    const { method, path } = definition;
    router[method.toLowerCase()](path, handlers[name]);
  });
}

// Usage
const router = express.Router();
applyRoutes(router, userRoutes, handlers);

module.exports = router;

Pattern 3: Domain-Driven Error Handling

Organize error handling by domain:

// domains/user/errors.js
class UserNotFoundError extends Error {
  constructor(userId) {
    super(`User with ID ${userId} not found`);
    this.statusCode = 404;
    this.code = 'USER_NOT_FOUND';
    this.name = 'UserNotFoundError';
  }
}

class UserValidationError extends Error {
  constructor(errors) {
    super('User validation failed');
    this.statusCode = 400;
    this.code = 'USER_VALIDATION_FAILED';
    this.name = 'UserValidationError';
    this.errors = errors;
  }
}

module.exports = {
  UserNotFoundError,
  UserValidationError
};

// domains/user/service.js
const { UserNotFoundError, UserValidationError } = require('./errors');

class UserService {
  async getUser(userId) {
    const user = await database.getUserAsync(userId);
    
    if (!user) {
      throw new UserNotFoundError(userId);
    }
    
    return user;
  }
  
  async createUser(userData) {
    const errors = this.validateUser(userData);
    
    if (Object.keys(errors).length > 0) {
      throw new UserValidationError(errors);
    }
    
    return await database.createUserAsync(userData);
  }
  
  validateUser(userData) {
    const errors = {};
    
    if (!userData.name) {
      errors.name = 'Name is required';
    }
    
    if (!userData.email) {
      errors.email = 'Email is required';
    } else if (!isValidEmail(userData.email)) {
      errors.email = 'Email is invalid';
    }
    
    return errors;
  }
}

// routes/userRoutes.js
const { UserService } = require('../domains/user/service');
const userService = new UserService();

router.get('/users/:id', catchAsync(async (req, res) => {
  // Service method throws domain-specific errors
  const user = await userService.getUser(req.params.id);
  res.json(user);
}));

// Error handling middleware understands domain errors
app.use((err, req, res, next) => {
  if (err.name === 'UserNotFoundError') {
    return res.status(err.statusCode).json({
      error: {
        message: err.message,
        code: err.code
      }
    });
  }
  
  if (err.name === 'UserValidationError') {
    return res.status(err.statusCode).json({
      error: {
        message: err.message,
        code: err.code,
        details: err.errors
      }
    });
  }
  
  // Handle other errors
  next(err);
});

Real-World Example: E-commerce Order Processing

In an e-commerce application, order processing involves multiple asynchronous operations:

// domains/order/service.js
class OrderService {
  async createOrder(userId, items) {
    // Check if user exists and can place orders
    const user = await this.userService.getUser(userId);
    
    if (!user.canPlaceOrders) {
      throw new OrderError('User is not allowed to place orders', 403, 'USER_RESTRICTED');
    }
    
    // Validate items and check stock
    for (const item of items) {
      await this.productService.checkAvailability(item.productId, item.quantity);
    }
    
    // Calculate total
    const total = await this.calculateTotal(items);
    
    // Create order in database
    const order = await this.orderRepository.create({
      userId,
      items,
      total,
      status: 'pending'
    });
    
    // Reserve inventory (can fail if stock changes)
    try {
      await this.inventoryService.reserveItems(order.id, items);
    } catch (error) {
      // Rollback order creation
      await this.orderRepository.delete(order.id);
      throw new OrderError('Failed to reserve inventory', 409, 'INVENTORY_RESERVATION_FAILED');
    }
    
    // Return created order
    return order;
  }
}

// routes/orderRoutes.js
router.post('/orders', catchAsync(async (req, res) => {
  const { userId, items } = req.body;
  
  // Validate request
  if (!userId || !items || !Array.isArray(items) || items.length === 0) {
    throw new ValidationError('Invalid order data');
  }
  
  // Delegate to service layer
  const order = await orderService.createOrder(userId, items);
  
  // Return success response
  res.status(201).json({
    message: 'Order created successfully',
    order
  });
}));

This example demonstrates how a complex business process with multiple potential points of failure can be handled with domain-specific error handling.

Testing Asynchronous Error Handling

Verifying that your error handling works correctly is crucial. Here are approaches to test asynchronous error handling in Express.

Unit Testing Error Handlers

// test/utils/catchAsync.test.js
const { expect } = require('chai');
const sinon = require('sinon');
const catchAsync = require('../../utils/catchAsync');

describe('catchAsync', () => {
  it('should pass errors to next function', async () => {
    // Arrange
    const error = new Error('Test error');
    const asyncFn = async () => {
      throw error;
    };
    
    const req = {};
    const res = {
      json: sinon.spy(),
      send: sinon.spy(),
      end: sinon.spy()
    };
    const next = sinon.spy();
    
    // Act
    const middleware = catchAsync(asyncFn);
    await middleware(req, res, next);
    
    // Assert
    expect(next.calledOnce).to.be.true;
    expect(next.calledWith(error)).to.be.true;
  });
  
  it('should not call next if response is already sent', async () => {
    // Arrange
    const asyncFn = async (req, res) => {
      // Send response first
      res.json({ message: 'Success' });
      
      // Then throw error
      throw new Error('Test error');
    };
    
    const req = {};
    const res = {
      json: sinon.spy(),
      send: sinon.spy(),
      end: sinon.spy()
    };
    const next = sinon.spy();
    
    // Act
    const middleware = catchAsync(asyncFn);
    await middleware(req, res, next);
    
    // Assert
    expect(res.json.calledOnce).to.be.true;
    expect(next.called).to.be.false;
  });
});

Integration Testing Async Routes

// test/routes/users.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../app');
const database = require('../../database');
const sinon = require('sinon');

describe('User Routes', () => {
  describe('GET /users/:id', () => {
    it('should return 404 for non-existent user', async () => {
      // Stub database response
      sinon.stub(database, 'getUserAsync').resolves(null);
      
      const response = await request(app)
        .get('/users/999')
        .expect('Content-Type', /json/)
        .expect(404);
      
      expect(response.body).to.have.property('error');
      expect(response.body.error.message).to.equal('User not found');
      
      // Restore stub
      database.getUserAsync.restore();
    });
    
    it('should handle database errors properly', async () => {
      // Stub database to throw error
      sinon.stub(database, 'getUserAsync').rejects(new Error('Database connection error'));
      
      const response = await request(app)
        .get('/users/1')
        .expect('Content-Type', /json/)
        .expect(500);
      
      expect(response.body).to.have.property('error');
      expect(response.body.error.message).to.equal('Internal Server Error');
      
      // Restore stub
      database.getUserAsync.restore();
    });
  });
});

Testing Complex Async Flows

// test/services/orderService.test.js
describe('OrderService', () => {
  describe('createOrder', () => {
    it('should handle inventory reservation failure', async () => {
      // Stub dependencies
      sinon.stub(userService, 'getUser').resolves({
        id: 1,
        name: 'Test User',
        canPlaceOrders: true
      });
      
      sinon.stub(productService, 'checkAvailability').resolves(true);
      
      sinon.stub(orderRepository, 'create').resolves({
        id: 123,
        userId: 1,
        items: [{ productId: 1, quantity: 2 }],
        total: 100,
        status: 'pending'
      });
      
      // Stub inventory service to fail
      sinon.stub(inventoryService, 'reserveItems').rejects(
        new Error('Not enough stock')
      );
      
      // Stub order deletion
      sinon.stub(orderRepository, 'delete').resolves(true);
      
      // Act & Assert
      try {
        await orderService.createOrder(1, [{ productId: 1, quantity: 2 }]);
        // Should not reach here
        expect.fail('Should have thrown an error');
      } catch (error) {
        expect(error.code).to.equal('INVENTORY_RESERVATION_FAILED');
        expect(orderRepository.delete.calledOnce).to.be.true;
      }
      
      // Restore stubs
      userService.getUser.restore();
      productService.checkAvailability.restore();
      orderRepository.create.restore();
      inventoryService.reserveItems.restore();
      orderRepository.delete.restore();
    });
  });
});

Testing Best Practices

When testing asynchronous error handling:

  • Test both successful flows and error scenarios
  • Verify that errors are properly propagated
  • Check that appropriate status codes and error messages are returned
  • Test edge cases like errors after response is sent
  • Use stubs for dependencies to simulate different error conditions
  • Test rollback mechanisms for complex operations

Practical Exercise

Build a Robust Async Error Handling System

In this exercise, you'll implement a complete asynchronous error handling system for an Express API.

Requirements

  1. Create a catchAsync utility function that handles different scenarios
  2. Implement domain-specific error classes
  3. Create a central error handler middleware
  4. Build API routes that use async/await with proper error handling
  5. Implement testing for your error handling

Steps

  1. Create the catchAsync.js utility with the enhanced version from this lecture
  2. Define at least 3 custom error types (e.g., NotFoundError, ValidationError, DatabaseError)
  3. Implement the central error handler middleware that formats errors consistently
  4. Create several Express routes using async/await and your error handling system
  5. Add unit tests to verify that your error handling works correctly

Sample API Endpoints

Implement the following endpoints with proper error handling:

  • GET /users/:id - Fetch a user by ID (handle "not found" case)
  • POST /users - Create a new user (handle validation errors)
  • GET /users/:id/orders - Fetch a user's orders (handle database errors)
  • POST /orders - Create an order (complex flow with multiple potential errors)

Testing Scenarios

Test your implementation with these scenarios:

  • Requesting a non-existent user
  • Creating a user with invalid data
  • Simulating a database connection error
  • Creating an order with insufficient inventory

Bonus Challenge

Extend your system to include:

  • Automatic error logging to a file
  • Handling race conditions in async operations
  • Implementing rollback mechanisms for failed operations

Summary

Key Takeaways

Best Practices

  1. Always handle errors in async operations
  2. Use a wrapper function like catchAsync to avoid repetitive try/catch blocks
  3. Create domain-specific error classes for better error organization
  4. Test both successful and error paths in your asynchronous code
  5. Consider using libraries like express-async-errors for simpler code
  6. Handle "headers already sent" scenarios in your error handling
  7. Implement global unhandled rejection handlers as a last resort
mindmap root((Async Error Handling)) Patterns catchAsync utility try/catch blocks express-async-errors Router wrappers Error Types Operational errors Programming errors Domain-specific errors Testing Unit tests Integration tests Error simulation Architecture Service layer errors Controller layer handling Middleware error formatting

Further Reading

Next Steps

In our next lecture, we'll explore Error Response Strategies, focusing on how to format error responses for different types of clients, implement error codes, and design a consistent error response system.

Practice Activities

Basic Exercises

  1. Implement the enhanced catchAsync utility presented in this lecture
  2. Convert a callback-based API to use promises and proper error handling
  3. Create a set of domain-specific error classes for a hypothetical application
  4. Write tests for error handling in async/await functions
  5. Implement a router wrapper that automatically adds async error handling

Advanced Project

Build an "Async Task Manager" Express API that demonstrates robust error handling: