
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:
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:
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); });

To prevent these crashes in Express 4.x, you needed to explicitly pass errors to
the next()
function:
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.


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:
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:
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:
// 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:
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:
app.use("/some-route", (err, req, res, next) => { // error handling });
Or attached directly to the route definition:
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:
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:
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:
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:

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:
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:
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:
npm install @appsignal/nodejs
Then set your AppSignal API key in your environment:
# .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.

Next, create an appsignal.js
file to initialize tracking:
// 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:
// 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:
// 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:
// 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()
:
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:

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

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

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

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:

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

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:
// 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:

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:


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:

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.

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:

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:
- Subscribe to our JavaScript Sorcery newsletter and never miss an article again.
- Start monitoring your JavaScript app with AppSignal.
- Share this article on social media
Most popular Javascript articles
Top 5 HTTP Request Libraries for Node.js
Let's check out 5 major HTTP libraries we can use for Node.js and dive into their strengths and weaknesses.
See moreWhen to Use Bun Instead of Node.js
Bun has gained in popularity due to its great performance capabilities. Let's see when Bun is a better alternative to Node.js.
See moreHow to Implement Rate Limiting in Express for Node.js
We'll explore the ins and outs of rate limiting and see why it's needed for your Node.js application.
See more

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 OlatunjiBecome our next author!
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!
