How to Build a Solid Error Handling System in Backends with Node.js and Express

When building a Node.js backend, error handling is something you cannot ignore. Imagine a user trying to access a route that does not exist or something unexpected happens on your server. Instead of throwing a messy error in the user’s face, you want to handle it gracefully and provide meaningful feedback. That’s where error handling comes into play.

In this blog, we will create a clean, reusable error-handling system using Node.js and Express. I shall walk you through the code, explaining each part in simple terms so you can understand how to handle errors like a pro.

Why Do We Need a Centralized Error Handling System?

Let’s picture this: you are building a RESTful API. Now, errors can come from all kinds of places—maybe someone requests a non-existent route, or there’s a typo in the data, or perhaps something broke in the system. Having a centralized error handling system allows you to catch these issues in one place, making it easier to log errors, notify the team, or provide the user with a helpful message instead of some ugly error stack.

This blog will help you:

  • Understand how to create custom error classes for different types of errors.
  • Set up a global error handler that will catch and handle errors in one central place.
  • Learn how to deal with expected and unexpected errors differently.

By the end, you will have a neat error handling system that you can drop into any Node.js backend project.

Setting Up the Project

First, we set up a simple Node.js project with Express. Let’s go through the basic steps before diving into the error handling part.

commands:

npm init -y 
npm install express nodemon

Here, npm init -y quickly sets up the project with default values, and then we install express for our server and nodemon so that our app restarts automatically whenever we make changes.

Next, we modify the package.json file to set up a custom script to run our app:

"local": "nodemon index.js"

This command lets you run the project locally by typing npm run local.

Creating the Express Server

In index.js, we initialize an Express app, set up routes, and implement the centralized error handling.

Here’s the code:

import express from "express";
import globalErrorHandler from "./src/middlewares/GlobalErrorHandler.js";
import AppRoutes from "./src/routes/index.js";

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const PORT = 3001;

app.use("/api", AppRoutes);

// Error handling
app.use(globalErrorHandler.handleApiNotFound);
app.use(globalErrorHandler.handleTrustedError);

app.listen(PORT, () => {
  console.info(`Express App running on ${PORT} port.`);
});

Let’s break it down:

  • We import Express and initialize the app.
  • We use express.json() and express.urlencoded() to handle incoming data in JSON format and form submissions, respectively.
  • The main routes are set up under /api, which will include all API-related endpoints.
  • Finally, we add two lines to handle errors:
  • globalErrorHandler.handleApiNotFound: This checks if someone is hitting a non-existent route.
  • globalErrorHandler.handleTrustedError: This deals with any other errors that come up.

The project structure looks like this:

Custom Error Classes

Now, let’s talk about error handling with custom error classes. In our app, not all errors are the same. For instance, a "Not Found" error is different from a "Validation Error." So, we need different classes for different types of errors.

Here’s where BaseError comes in. In BaseError.js, we create a class that extends the default JavaScript Error class. This will be the parent class for all our custom errors.

class BaseError extends Error {
  constructor(name, statusCode, isOperational, success, message) {
    super(message); 
    this.name = name;
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    this.success = success;
    Error.captureStackTrace(this, this.constructor);
  }
}

export default BaseError;

Let’s break this down:

  • name: The name of the error, like "Not Found" or "Validation Error"
  • statusCode: The HTTP status code, like 404 or 500.
  • isOperational: This is used to distinguish between expected errors (like validation issues) and unexpected system errors.
  • success: A flag indicating if the operation succeeded. Spoiler: it’s always false for errors.
  • Error.captureStackTrace(this, this.constructor) helps in excluding the constructor from the stack trace, making the error stack cleaner and more helpful.

API Errors and Not Found Errors

We now create two custom error classes in ApiErrors.jsApiError for general server issues and ApiNotFound for 404 errors.

import BaseError from "./BaseError.js";

class ApiError extends BaseError {
  constructor(name = "Internal Server Error", statusCode = 500, isOperational = true, success = false, message = "Something went wrong on the server's end") {
    super(name, statusCode, isOperational, success, message);
  }
}

class ApiNotFound extends BaseError {
  constructor(message = "The requested resource could not be found") {
    super("Not Found", 404, true, false, message);
  }
}

export { ApiError, ApiNotFound };

In ApiError, we set default values for when the server breaks unexpectedly, like a 500 error.

ApiNotFound is specific to 404 errors and uses a default message unless you provide one.

Global Error Handling Middleware

Now comes the part where we handle all errors in one place—GlobalErrorHandler.js. This middleware will intercept errors and decide how to handle them.

import { ApiNotFound, errorHandler } from "../utils/index.js";

class GlobalErrorHandler {
  handleApiNotFound(req, res, next) {
    const API_NOT_FOUND = new ApiNotFound(`The requested path ${req.path} not found!`);
    next(API_NOT_FOUND);
  };

  handleTrustedError(error, req, res, next) {
    if (!errorHandler.isTrustedError(error)) {
      return next(error);
    }
    return res.status(error.statusCode || 500).json({
      name: error.name,
      statusCode: error.statusCode,
      success: error.success,
      message: error.message
    });
  };
}

const globalErrorHandler = new GlobalErrorHandler();
export default globalErrorHandler;

In handleApiNotFound, we check if the user is trying to access a route that doesn’t exist. We throw an ApiNotFound error and pass it to the next middleware.

handleTrustedError is where we handle errors we expect (like validation issues). If it’s an operational error (like something we know can happen), we return a nice JSON response with all the details.

Key Point: What does it mean "pass it to the next middleware"

When we say, "pass it to the next middleware," we’re talking about how Express handles requests in a series of steps or functions, which are called middlewares. Middleware functions are like checkpoints that your request goes through, one after the other. Each middleware can either process the request or pass it along to the next one in line by calling next().

So, when we encounter an error, we pass it to the next middleware using next(error). This lets Express know that something went wrong and that it should skip any other normal processing middlewares and jump straight to the error-handling ones—like our global error handler.

It’s kind of like telling the system, “Hey, something isn’t right here. Let’s move on to error-handling now.

Testing the Error System

Finally, let’s look at a route where we intentionally trigger an error. In testErrorApiController.js, we simulate a validation error by checking if a name is null.

import { ApiError } from "../utils/index.js";

const testErrorApi = async (req, res, next) => {
  try {
    const name = null;
    if(!name) {
      throw new ApiError("ValidationError", 400, true, false, "Name is null.");
    }
    return res.status(200).json({data: name});
  } catch (error) {
    next(error);
  }
};
export default testErrorApi;

Here, we check if the name is null. If it is, we throw a ValidationError with status code 400. The error gets passed down to the global error handler, which takes care of the rest.

Lets test testErrorApiController.js API via Postman:

Test API not found error. Paste this link in postman: localhost:3001/test-error

Now test the API error in postman. Here is link: localhost:3001/api/test-error

Conclusion

With this setup, you have got a solid error handling system in place for your Node.js and Express app. Whenever something goes wrong—whether it's a missing route or a validation error—you are catching it, logging it, and responding to the user with a helpful message.

This system is not only about handling errors but also about making sure that your code is organized and easy to maintain. You don’t want to sprinkle error handling all over your routes. Instead, centralizing everything makes things much cleaner and manageable.

Happy coding, and may your servers run smoothly with this error handling system in place.