Express.js Error Handling

Error handling is the backbone of any production-ready application. Without a solid strategy, your server might crash unexpectedly, or worse, leak sensitive system information to your users. In Express.js, error handling is implemented through a specialized type of middleware that catches issues before they reach the client, allowing you to provide helpful feedback and keep your service running smoothly.

Developer Tip: Think of error handling as a safety net. Your goal isn't just to catch errors, but to categorize them so you can decide which ones need immediate attention from your DevOps team and which ones are just user input mistakes.

 

Key Features of Error Handling

  • Centralized Error Management: Instead of writing try-catch blocks with custom responses in every single route, you can funnel all errors into one specialized function.
  • Custom Error Responses: You can distinguish between a "Database Connection Timeout" (500 Internal Server Error) and a "User Not Found" (404 Not Found) to give the front-end meaningful status codes.
  • Debugging Support: During development, you can log full stack traces, while in production, you can keep logs concise and secure.
  • Integration with Middleware: Express handles errors through the same "pipeline" concept as your regular logic, making it easy to plug in tools like Sentry or Bugsnag.

 

Setting Up Error Handling in Express.js

Default Error Handler
Express comes with a built-in error handler that catches any errors occurring in your routes. If you don't define your own, Express will send a 500 status code and an HTML error page. However, for modern APIs, you'll want to override this with a JSON response or a custom view.

Watch Out: By default, Express includes the stack trace in the response. This is great for local development but is a security risk in production, as it reveals your folder structure and code logic to potential attackers.

Example of a basic custom error handler:

app.use((err, req, res, next) => {
    console.error(err.stack); // Log the error for the developer
    res.status(500).json({
        success: false,
        message: 'Something went wrong on our end!',
        error: process.env.NODE_ENV === 'development' ? err.message : {}
    });
});
Common Mistake: Forgetting the next parameter. Error-handling middleware must have four arguments: (err, req, res, next). If you provide only three, Express will treat it as regular middleware, and it won't catch errors correctly.

Custom Error Middleware
You can create logic that checks the "type" or "code" of an error. This is incredibly useful for handling specific scenarios like Stripe payment failures or database validation errors.

Example:

app.use((err, req, res, next) => {
    if (err.name === 'ValidationError') {
        return res.status(400).send({ error: 'Invalid data provided' });
    }
    
    // If it's not a validation error, pass it to the next error handler
    next(err); 
});

Handling 404 Errors
Technically, a 404 (Not Found) isn't an "error" in Express—it's simply the result of no route matching the request. To handle this, you place a middleware function at the very bottom of your file, after all other routes.

Best Practice: Always place your 404 handler after all app.get and app.post routes, but before your global error handler.

Example:

app.use((req, res, next) => {
    res.status(404).send('Sorry, we couldn’t find that page!');
});

Error Throwing in Routes
In synchronous code, you can simply throw an error. However, for more control, or when working in middleware, use next(err). When you pass an argument to next(), Express assumes it's an error and skips all remaining non-error middleware.

Example:

app.get('/profile/:id', (req, res, next) => {
    const user = database.find(req.params.id);
    if (!user) {
        const error = new Error('User not found');
        error.status = 404;
        return next(error); // Forwarding to our error handler
    }
    res.send(user);
});

Using Asynchronous Error Handlers
This is where most developers run into trouble. If an error occurs inside an async function or a callback, you must pass it to next(). If you don't, your server might hang or crash without sending a response.

Developer Tip: In Express 5 (the newer version), errors thrown in async routes are automatically caught. However, in Express 4, you must always use try-catch or a wrapper.

Example (Express 4 style):

app.get('/dashboard', async (req, res, next) => {
    try {
        const report = await fetchReport();
        res.send(report);
    } catch (err) {
        next(err); // This sends the error to your global handler
    }
});

Error Logging
Console logs are lost when a server restarts. In a real-world app, you should use a logging library like winston or pino to write errors to a file or an external service.

Example with Morgan (for request logging):

const morgan = require('morgan');
// Log all requests to the terminal
app.use(morgan('dev')); 

 

Complete Example

Here is how you would organize a clean Express entry point with proper error handling order.

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

// 1. Regular Routes
app.get('/', (req, res) => {
    res.send('Welcome to the Homepage');
});

app.get('/trigger-error', (req, res, next) => {
    const err = new Error('Database connection failed!');
    err.status = 503;
    next(err);
});

// 2. 404 Catch-all (for routes that don't exist)
app.use((req, res, next) => {
    res.status(404).json({ message: "Route not found" });
});

// 3. Global Error Handler (must be last)
app.use((err, req, res, next) => {
    const statusCode = err.status || 500;
    console.error(`[Error] ${err.message}`);
    
    res.status(statusCode).json({
        error: err.message,
        stack: process.env.NODE_ENV === 'production' ? '🥞' : err.stack
    });
});

app.listen(3000, () => {
    console.log('Server is running on http://localhost:3000');
});

 

Summary

Mastering error handling in Express.js is about more than just preventing crashes; it's about creating a professional interface for users and a maintainable environment for developers. By using a centralized error handler, respecting the middleware order, and properly managing asynchronous code, you ensure that your application remains robust, secure, and easy to debug.