Express.js Error Handling Middleware

In a production-grade web application, things will inevitably go wrong—a database connection might time out, a user might request a resource that doesn't exist, or an external API might fail. Error handling middleware is your safety net. It allows you to intercept these failures, log them for debugging, and return a clean, professional response to the user instead of letting the application crash or hang.

Developer Tip: Think of error handling middleware as a "global catch block" for your entire Express application. It ensures that no matter where an error occurs, you have a consistent way to deal with it.

 

Key Features of Error Handling Middleware

  • Centralized Error Handling: Instead of writing try-catch blocks in every single route to send responses, you can delegate that responsibility to a single piece of middleware at the end of your pipeline.
  • Customizable Responses: You can decide exactly what the client sees. For example, you might send a full stack trace during local development but a generic "Internal Server Error" message in production to keep your server details secure.
  • Handles Synchronous and Asynchronous Errors: Express provides mechanisms to catch errors from standard functions, Promises, and async/await blocks, ensuring your app stays resilient regardless of the coding style.
Best Practice: Always separate your error logs from your user responses. Log the full error details (including the stack trace) to your console or a logging service like Winston or Morgan, but keep the response to the client brief and secure.

 

Steps to Implement Error Handling Middleware

Basic Error Handling Middleware
Express recognizes error-handling middleware by the number of arguments it accepts. While standard middleware uses three arguments (req, res, next), error-handling middleware must have exactly four: err, req, res, and next.

Example:

const express = require('express');
const app = express();

// A route that simulates a failure
app.get('/error', (req, res, next) => {
    const error = new Error('Database connection failed!');
    // When you pass an argument to next(), Express skips all remaining 
    // non-error middleware and goes straight to the error handler.
    next(error); 
});

// The error handling middleware - MUST have 4 arguments
app.use((err, req, res, next) => {
    console.error('Logging Error:', err.message);
    
    // Set the status code (default to 500 if not specified)
    const statusCode = res.statusCode === 200 ? 500 : res.statusCode;
    
    res.status(statusCode).json({
        message: err.message,
        // Only show stack trace in development mode
        stack: process.env.NODE_ENV === 'production' ? '🥞' : err.stack,
    });
});

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});
Common Mistake: Forgetting the fourth argument (next). Even if you don't use the next object inside your error handler, you must include it in the function signature. If you only provide three arguments, Express will treat it as regular middleware, and it won't catch your errors.

Error Handling for Asynchronous Code
In Express 4.x, errors thrown in synchronous code are caught automatically. however, for asynchronous code (like database queries), you must manually catch the error and pass it to next().

Example with Promises:

app.get('/async-error', (req, res, next) => {
    // Simulating a database call that fails
    fetchDataFromDB()
        .then(data => res.json(data))
        .catch(next); // This is shorthand for .catch(err => next(err))
});

Example with async/await:

app.get('/async-await-error', async (req, res, next) => {
    try {
        const user = await User.findById(req.params.id);
        if (!user) {
            throw new Error('User not found');
        }
        res.json(user);
    } catch (err) {
        // Essential: If you don't call next(err), the request will hang
        next(err); 
    }
});
Watch Out: If you are using Express 4, unhandled exceptions in async functions will not be caught by your error middleware automatically. You must wrap your logic in try/catch or use a wrapper library like express-async-errors.

Custom Error Types
Using generic errors is fine for small projects, but for larger apps, creating custom error classes helps you distinguish between "Operational Errors" (like an invalid password) and "Programmer Errors" (like a typo in a variable name).

Example:

class AppError extends Error {
    constructor(message, statusCode) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = true; // Useful to distinguish known errors from bugs
    }
}

app.get('/protected-route', (req, res, next) => {
    const isAuthorized = false;
    if (!isAuthorized) {
        return next(new AppError('You are not authorized to access this', 403));
    }
    res.send('Welcome, Admin!');
});

app.use((err, req, res, next) => {
    const status = err.statusCode || 500;
    res.status(status).send({ 
        status: 'fail',
        message: err.message 
    });
});

Default Error Handling Middleware
Express comes with a built-in error handler that takes care of any errors you might have missed. However, once you define your own custom error middleware, it replaces the default one.

Best Practice: Always place your error-handling middleware at the very end of the middleware stack, after all your app.use() and route definitions. This ensures that any error occurring in any route eventually "bubbles down" to the handler.

Error Handling for Specific Routes
Sometimes, you want specific logic for certain parts of your app. For example, your API might return JSON errors, while your website frontend should return a rendered HTML "404" page.

Example:

const apiRouter = express.Router();

// Error handler specifically for API routes
apiRouter.use((err, req, res, next) => {
    console.error('API Error:', err);
    res.status(400).json({ error: 'API Request failed', details: err.message });
});

apiRouter.get('/data', (req, res, next) => {
    next(new Error('Data unavailable'));
});

app.use('/api/v1', apiRouter);

 

Example of Complete Error Handling Setup

Here is a real-world pattern that includes body parsing, a route, and a robust global error handler.

const express = require('express');
const app = express();

app.use(express.json());

// 1. Regular Route
app.get('/user/:id', async (req, res, next) => {
    try {
        // Logic here
        if (req.params.id === '0') throw new Error('Invalid User ID');
        res.send('User Data Found');
    } catch (err) {
        next(err);
    }
});

// 2. Handle 404 (Route Not Found)
// This is not an error handler, but a catch-all for missing routes
app.use((req, res, next) => {
    res.status(404).send('Sorry, we could not find that page.');
});

// 3. Global Error Handler
app.use((err, req, res, next) => {
    const status = err.status || 500;
    console.error(`[Error] ${req.method} ${req.url}: ${err.message}`);
    
    res.status(status).json({
        success: false,
        message: err.message || 'Internal Server Error'
    });
});

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});

 

Summary

Error handling middleware in Express is a powerful tool that transforms your application from a fragile script into a robust service. By implementing a centralized error handler with the four-argument signature (err, req, res, next), you ensure that your application can recover gracefully from failures. Remember to handle both synchronous and asynchronous errors, use custom error classes for better organization, and always place your error handlers at the end of the middleware stack to catch everything that falls through.