javascript

Express for Node Error Handling and Tracking Done Right

Damilola Olatunji

Damilola Olatunji on

Express for Node Error Handling and Tracking Done Right

Error handling might not be the most exciting aspect of web development, but it's arguably one of the most critical.

When building Express applications, how you manage errors can make the difference between a robust, production-ready system and one that crumbles under real-world conditions.

In this article, we'll examine Express' default error handling behavior and learn how to customize it for different scenarios.

We'll also cover how to implement error tracking to gain visibility into production errors, set up alerts for critical issues, and even automate the creation of GitHub issues from error reports.

By the end, you'll know how to transform error handling from an afterthought into a key component of your development process.

Let's get started!

How Express Handles Errors by Default

Before we discuss error tracking, let's examine Express' default behavior when it comes to error handling.

Express.js has a built-in error handling system that operates in several important ways. By default, errors are handled through its built-in error handler middleware, which gets triggered when an error occurs during request processing.

Uncaught synchronous errors are automatically caught by the default error handler like this:

JavaScript
app.get("/example", (req, res) => { throw new Error("Something broke!"); // Express catches this automatically });

In Express 4.x, uncaught asynchronous errors led to the application crashing:

JavaScript
app.get("/file", async (req, res) => { const data = await fs.readFile("example.txt", "utf8"); // an error here will crash the entire server res.send(data); });
Screenshot of error crashing the server

To prevent these crashes in Express 4.x, you needed to explicitly pass errors to the next() function:

JavaScript
app.get("/file", async (req, res) => { try { const data = await fs.readFile("example.txt", "utf8"); res.send(data); } catch (err) { next(err); // This will trigger the default error handler } });

Starting with Express 5, this is no longer necessary, as async errors are also caught in the same manner as synchronous errors (next(err) is now called automatically).

Customizing the Default Error Handler

By default, Express responds with a 500 internal server error, generating either a detailed HTML stack trace in development mode or a simplified error page in production as determined by your NODE_ENV setting.

Development error response
Production error response

Regardless of the environment, Express logs all errors to the console to help you identify the root cause of a failed request.

To override this default behavior, you can create your own error handling middleware and place it after all other middleware and route handlers:

JavaScript
app.use((err, req, res, next) => { // Custom error handling logic });

Express identifies error handling middleware by its signature of four parameters. Even if you don't use all four parameters in your implementation, the function must declare them for Express to recognize it as an error handler.

Your custom error handler replaces the default one, giving you complete control over:

  • Response format and content
  • Error tracking strategy
  • HTTP status codes based on error types
  • Client-facing error information

For example:

JavaScript
app.use((err, req, res, next) => { console.error(`${err.name}: ${err.message}`); if (err instanceof ValidationError) { return res.status(400).json({ status: "error", type: "validation", message: err.message, }); } if (err instanceof DatabaseError) { console.error("Database Error Details:", err.details); return next(err); } // Default error response res.status(500).json({ status: "error", message: process.env.NODE_ENV === "production" ? "Internal server error" : err.message, }); });

You can register multiple error handlers by adding several middleware functions with the (err, req, res, next) signature. They are executed in registration order, similar to regular middleware functions.

When you call next(err) within an error handler, Express skips any regular middleware and jumps directly to the next error handling middleware. This enables a pattern where different handlers address specific error types:

JavaScript
// Validation error handler app.use((err, req, res, next) => { if (err.name !== "ValidationError") { next(err); // go to the next handler if the error is not ValidationError } return res.status(400).json({ message: "Invalid input", details: err.details, }); }); // Catch-all error handler app.use((err, req, res, next) => { console.error("Unhandled error:", err); res.status(500).json({ message: "Internal server error", }); // If next(err) is used here, it will reach the default error handler });

While global error handlers are powerful for managing application-wide errors, sometimes you need more granular control for specific routes or API endpoints.

Let's explore how to implement route-specific error handling to provide more tailored error responses based on the context of different routes in your application.

Handling Route-specific Errors

The easiest way to implement custom error handling for specific routes is by wrapping your route handler code in a try/catch block:

JavaScript
app.get("/some-route", (req, res) => { try { throw new Error("Something happened!"); } catch (err) { console.log(err); res.status(400).json({ message: "Handled error for /some-route" }); } });

This approach lets you catch exceptions and handle them directly within the route handler without relying on global error handling middleware.

Alternatively, you can create route-specific error handling middleware. This can be implemented as a standalone middleware function:

JavaScript
app.use("/some-route", (err, req, res, next) => { // error handling });

Or attached directly to the route definition:

JavaScript
app.get( "/some-route", (req, res) => { // route logic }, (err, req, res, next) => { // error handling } );

For groups of routes that share similar error handling requirements, you can apply the same error handler to all of them using Express routers:

JavaScript
const router = express.Router(); router.use("/admin", adminRoutes); // Error handler specific to admin routes router.use("/admin", (err, req, res, next) => { res.status(500).json({ message: "Admin route error", error: err.message, }); });

Now that you know how to customize your error handling for specific routes, let's examine how to design useful custom error classes that enhance debugging and improve the clarity of error responses.

Creating Useful Errors

When working with errors in Node.js applications, the standard Error object only provides basic information, which may not be sufficient to understand the root cause of an issue.

Real-world applications encounter diverse problems, such as invalid user inputs, dropped database connections, or network timeouts, with each requiring different handling approaches. These distinct scenarios call for more specialized error types.

Creating custom error classes is a necessary step toward making your errors more informative and actionable. Instead of generic errors, you can throw specific types like ValidationError for invalid inputs or TimeoutError when operations exceed time limits.

Custom errors retain all standard properties like messages and stack traces while allowing you to add context-specific details. For validation failures, you might include the problematic field and the specific rule that wasn't satisfied.

This additional context helps your error handlers make better decisions about response strategies and provides more useful feedback to users.

For instance, when a network request fails, you will likely see a generic error like this:

Shell
TypeError: fetch failed at node:internal/deps/undici/undici:12442:11 at process.processTicksAndRejections (node:internal/process/task_queues:95:5) at async fetchUserData (file:///home/dami/expressjs-errors/main.js:40:19) at async file:///home/dami/expressjs-errors/main.js:54:2

Or, in the case of a timeout, something like this:

Shell
DOMException [TimeoutError]: The operation was aborted due to timeout at node:internal/deps/undici/undici:12442:11 at async file:///home/dami/expressjs-errors/main.js:68:20

If we use an error tracker like AppSignal, it could appear as follows:

TimeoutError in AppSignal

While these errors are useful, they lack critical context about why the request failed, which specific endpoint was affected, what parameters were used, or how many retries were attempted.

To improve the error, you can create a custom error class specifically for tracking those kinds of failures in your application. Here's an example:

JavaScript
class AppError extends Error { constructor(message, options = {}) { // Pass both message and cause to parent constructor super(message, { cause: options.cause }); this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); this.status = options.status || 500; this.code = options.code || "UNKNOWN_ERROR"; this.context = options.context || {}; this.timestamp = new Date(); } } class APIError extends AppError { constructor(message, options = {}) { // Pass both message and cause to parent constructor super(message, { code: "API_ERROR", status: options.status || 500, ...options, }); } } app.get("/todos", async (req, res, next) => { const endpoint = "https://jsonplaceholder.typicode.com/todos/1"; try { const response = await fetch(endpoint, { signal: AbortSignal.timeout(1000), // Abort after a second }); if (!response.ok) { throw new Error(response.statusText); } const data = await response.json(); res.json(data); } catch (err) { next( new APIError(`Network request failed to: ${endpoint}`, { cause: err, context: { endpoint, }, }) ); } });

The snippet defines two error classes: First, AppError serves as a base error class that extends the native Error, adding a custom status code (defaulting to 500), an error code identifier, a context object for additional details, a timestamp of when the error occurred, and proper stack trace capture.

Second, APIError is a specialized error class for API-related failures that extends AppError, setting default values specific to API failures.

The Express route handler for /todos attempts to fetch data from an external API with a one-second timeout. If the request fails, it catches the error and creates a new APIError instance with a descriptive message, the original error, and additional context for debugging.

This enhanced error is then passed to Express's error handling middleware via next(error), yielding more helpful error logs that look like this:

Shell
APIError: Network request failed to: https://jsonplaceholder.typicode.com/todos/1 at file:///home/dami/expressjs-errors/main.js:82:4 { status: 500, code: 'API_ERROR', context: { endpoint: 'https://jsonplaceholder.typicode.com/todos/1' }, timestamp: 2025-03-19T07:26:21.513Z, [cause]: DOMException [TimeoutError]: The operation was aborted due to timeout at node:internal/deps/undici/undici:12442:11 at async file:///home/dami/expressjs-errors/main.js:70:20 }

Here, you can see the endpoint that was affected and the exact reason for the failure (a network timeout in this case).

Now that we've explored how Express handles errors by default, the next step is to track and monitor them effectively.

Tracking Your Express Errors

Error tracking is the process of capturing and analyzing errors from your application to allow for quick resolution.

When an error occurs, a tracking system collects essential details about the error type, the application's state, the affected user, and their device or browser information.

This data is sent to a specialized platform that organizes and analyzes the errors. These platforms can identify anomalies and group similar errors together, making it easier to identify trends and prioritize fixes.

AppSignal is one such solution for tracking Express errors, and offers a straightforward integration using the AppSignal Node.js SDK.

First, install the SDK with:

Shell
npm install @appsignal/nodejs

Then set your AppSignal API key in your environment:

Shell
# .env APPSIGNAL_PUSH_API_KEY=<your_key>

Once you've registered for an AppSignal account, you can find this key in your application settings under the Push & deploy section.

Appsignal push & deploy settings

Next, create an appsignal.js file to initialize tracking:

JavaScript
// appsignal.js import { Appsignal } from "@appsignal/nodejs"; new Appsignal({ active: true, name: "Express Demo", });

Note that this file needs to be imported in your application entry script before all other imports:

JavaScript
// server.js import "dotenv/config"; // load environmental variables from .env import "./appsignal.js"; // ... rest of the imports

Finally, ensure that AppSignal's error handling middleware is registered after all routes, but before any other error handlers:

JavaScript
// server.js import { expressErrorHandler } from "@appsignal/nodejs"; . . . app.use(expressErrorHandler());

This Express integration reports any errors raised by request handlers or other middleware to AppSignal before calling next(err) to continue to the next error handler.

See the implementation of the handler:

JavaScript
// error_handler.ts import { NextFunction } from "express"; import { setError } from "../../helpers"; export function expressErrorHandler() { return function ( err: Error & { status?: number }, req: any, res: any, next: NextFunction ) { if (!err.status || err.status >= 500) { setError(err); } return next(err); }; }

AppSignal only reports errors whose status property does not exist or is set to 500 and above.

If you'd like to customize error reports, you can create a custom middleware that incorporates the setError() function instead of using the expressErrorHandler():

JavaScript
import { setError } from "@appsignal/nodejs"; // . . . app.use((err, req, res, next) => { setError(err); // ... rest of error handling });

Once AppSignal starts receiving errors, you'll see them in the Errors section of your AppSignal dashboard:

Errors in AppSignal

When a new error is tracked, you'll also get an email notification informing you of the need to triage it:

AppSignal Email notification

You can click on the error in AppSignal and switch to the Samples tab to inspect the tracked instances of that specific error:

AppSignal Error samples

Clicking on a specific sample will display the error message, stack trace, and other contextual information about the application environment when the error occurred:

AppSignal error sample

There are several ways to enhance your error tracking experience in AppSignal. Let's explore some key features you might find helpful:

Creating GitHub Issues From Errors

AppSignal's GitHub integration enables you to link stack traces directly to specific lines in your application's source code and create issues from error instances.

To enable this feature, configure your repository URL by navigating to App Settings > Integrations, then find the Git tracker for your application (such as GitHub or GitLab) and click Configure:

AppSignal integrations

After connecting your GitHub account, you'll be able to link the appropriate repository to your AppSignal instance:

AppSignal GitHub integration

You also need to enable deploy markers so AppSignal can identify which version of your application was running when an error occurred. This is valuable for distinguishing between errors in your latest version versus historical issues.

To implement deploy markers, modify your appsignal.js file as follows:

JavaScript
// appsignal.js import childProcess from "node:child_process"; import { Appsignal } from "@appsignal/nodejs"; const REVISION = childProcess.execSync("git rev-parse --short HEAD").toString(); new Appsignal({ active: true, name: "Express Demo", revision: REVISION, // sets the Git revision });

After saving these changes, commit and push your code to GitHub. With this set up, you can now filter errors by Git commit in the AppSignal UI:

Filtering by Git commit in AppSignal

You can also create issues from an error by using the Send to GitHub action which allows you to customize the issue title and description before it's created in GitHub:

Send to GitHub button
Issue created by AppSignal in GitHub

The issue will also be associated with the corresponding error in your AppSignal dashboard.

Configuring Error Rate Alerts

Error rate alerts enable you to monitor your applications and receive notifications when AppSignal detects elevated levels of 5xx error responses.

To set this up, navigate to the Anomaly detection > Triggers section, and click Add a trigger:

Anomaly detection in AppSignal

Next, select the Error rate entry under Actions to configure your alerting conditions. You can specify parameters, such as alerts being sent when an error rate exceeds 10%, and set warm-up and cool-down durations to prevent alerts for temporary issues.

Error rate anomaly configuration

After selecting your preferred notification method, click Save Trigger. With this configuration in place, you'll receive an alert whenever 5xx errors exceed your specified threshold (e.g., 10% of all requests) within the defined warm-up period:

Appsignal Anomaly alert

Wrapping Up

AppSignal offers far more capabilities beyond what we've covered in this guide, including performance monitoring, host metrics tracking, custom dashboards, uptime monitoring, logging, and much more!

For a comprehensive understanding of how AppSignal can enhance your application monitoring and error handling workflow, we recommend exploring the Express documentation where you'll find more detailed guides on all the features it has to offer.

Thanks for reading, and happy debugging!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Damilola Olatunji

Damilola Olatunji

Damilola is a freelance technical writer and software developer based in Lagos, Nigeria. He specializes in JavaScript and Node.js, and aims to deliver concise and practical articles for developers. When not writing or coding, he enjoys reading, playing games, and traveling.

All articles by Damilola Olatunji

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps