Avoiding Try-Catch Antipatterns in Express Routes
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Introduction
In the world of Node.js and web development, Express.js is a ubiquitous framework for building robust APIs and web applications. As developers, we constantly strive for clean, maintainable, and resilient code. When it comes to handling errors, especially in asynchronous operations that are inherent to web servers, the try-catch block often comes to mind as the go-to solution. However, a common pitfall in Express applications is the pervasive use of try-catch directly within every route handler. While seemingly straightforward, this approach can quickly lead to an error handling antipattern, introducing boilerplate, reducing readability, and hindering future refactoring. This article delves into why this practice is problematic and explores more elegant, scalable solutions for managing asynchronous errors in Express.js.
The Problem with Widespread Try-Catch
Before we dissect the antipattern, let's briefly define some core terms relevant to Express.js error handling:
- Route Handler: A function that Express executes when a specific route is matched. It typically takes 
req,res, andnextas arguments. - Middleware: Functions that have access to the request and response objects, and the next middleware function in the application’s request-response cycle. They can execute code, make changes to the request and response objects, end the request-response cycle, or call the next middleware.
 - Error Middleware: A special type of middleware in Express (defined with four arguments: 
err,req,res,next) that is specifically designed to catch and process errors that occur during the request-response cycle. - Asynchronous Operations: Tasks that don't block the main thread of execution, such as database queries, network requests, or file I/O. In JavaScript, these are commonly handled with Promises and 
async/await. 
The Antipattern Explained
Consider a typical Express route handler that performs an asynchronous operation, like fetching data from a database:
// A common, yet problematic, approach app.get('/users/:id', async (req, res) => { try { const user = await UserModel.findById(req.params.id); if (!user) { return res.status(404).send('User not found'); } res.json(user); } catch (error) { console.error('Error fetching user:', error); res.status(500).send('Something went wrong'); } });
At first glance, this code seems perfectly fine. It handles potential errors during the database query and responds appropriately. However, imagine an application with dozens, or even hundreds, of such routes. Each route handler will likely have its own try-catch block, repeating the same error handling logic: logging the error and sending a 500 response. This leads to:
- Boilerplate Repetition: Duplicating 
try-catchblocks everywhere makes the code verbose and clutters the meaningful business logic. - Reduced Readability: The core purpose of the route handler (fetching and returning a user) gets buried under error handling concerns.
 - Maintenance Overhead: If you need to change how errors are logged or how 500 responses are structured (e.g., adding a specific error code), you'd have to modify every single 
try-catchblock. - Inconsistent Error Responses: Developers might implement slightly different error responses in different 
try-catchblocks, leading to an inconsistent API. - Harder Debugging: Pervasive 
try-catchcan sometimes hide the true origin of an error if not handled carefully, making debugging more challenging. 
The fundamental issue is that async/await errors that are caught synchronously within a route handler will not automatically propagate to Express's global error middleware. For this to happen, the error must be explicitly passed to the next function.
Better Solutions
The good news is that Express provides mechanisms to handle asynchronous errors much more gracefully.
1. Using next(error)
The most direct way to fix the try-catch antipattern while still using try-catch for specific, localized error handling is to explicitly pass the caught error to the next function. This tells Express to skip subsequent middleware and route handlers and instead invoke the error-handling middleware.
app.get('/users/:id', async (req, res, next) => { // Don't forget 'next' try { const user = await UserModel.findById(req.params.id); if (!user) { // For application-specific errors, you might want to create custom error classes return res.status(404).send('User not found'); } res.json(user); } catch (error) { next(error); // Pass the error to the error-handling middleware } }); // A global error-handling middleware (must be defined last) app.use((err, req, res, next) => { console.error('Caught by error middleware:', err.stack); // Log the stack trace for debugging res.status(err.statusCode || 500).json({ message: err.message || 'Something went wrong', error: process.env.NODE_ENV === 'development' ? err : {} // Only send detailed error in dev }); });
This approach centralizes the response logic for errors in the error middleware, while still allowing try-catch to handle the catching of errors within the route.
2. Using an Asynchronous Wrapper (Higher-Order Function)
Even better, we can completely abstract away the try-catch block from the route handler using a higher-order function:
// utils/asyncHandler.js const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; // app.js // ... other imports and middleware app.get('/users/:id', asyncHandler(async (req, res) => { const user = await UserModel.findById(req.params.id); if (!user) { // You might throw a custom error here that includes a status code throw new Error('User not found'); } res.json(user); })); app.post('/products', asyncHandler(async (req, res) => { const newProduct = await ProductModel.create(req.body); res.status(201).json(newProduct); })); // Global error-handling middleware (same as above) app.use((err, req, res, next) => { console.error('Caught by error middleware:', err.stack); res.status(err.statusCode || 500).json({ message: err.message || 'Something went wrong', error: process.env.NODE_ENV === 'development' ? err : {} }); });
Here's how asyncHandler works:
- It takes an async function (
fn) as an argument. - It returns a new middleware function 
(req, res, next). - Inside this new function, it executes the original 
fn. Promise.resolve(fn(...))ensures that even iffnisn't explicitlyasync, its return value is treated as a Promise..catch(next)catches any errors thrown or rejected withinfn(or the Promisesfnawaits) and passes them directly to Express'snextfunction, which then triggers the global error middleware.
This pattern completely removes the try-catch boilerplate from your individual route handlers, making them much cleaner and focused on business logic.
3. Using Libraries for Async Error Handling
For even more convenience and robust error handling, specialized libraries can be used. A popular choice is express-async-errors.
// index.js or app.js require('express-async-errors'); // Import at the very top of your application const express = require('express'); const app = express(); // ... other middleware app.get('/users/:id', async (req, res) => { const user = await UserModel.findById(req.params.id); if (!user) { throw new Error('User not found'); } res.json(user); }); // Any route handler that throws an error will automatically be caught by: app.use((err, req, res, next) => { console.error('Caught by error middleware:', err.stack); res.status(err.statusCode || 500).json({ message: err.message || 'Something went wrong', error: process.env.NODE_ENV === 'development' ? err : {} }); }); // ... start server
By simply importing express-async-errors once, it patches Express to automatically catch unhandled promise rejections in async route handlers and pass them to your error middleware, eliminating the need for asyncHandler wrappers or explicit try-catch blocks within every route handler.
Conclusion
While try-catch is a fundamental tool for error handling, its pervasive use directly within every asynchronous Express route handler is an antipattern that leads to messy, repetitive, and hard-to-maintain code. By leveraging Express's next(error) mechanism, creating reusable asyncHandler wrappers, or incorporating specialized libraries like express-async-errors, developers can centralize and streamline asynchronous error management. This results in cleaner, more readable route handlers focused on business logic, and a more robust, consistent error handling strategy across their Express applications. Embrace these patterns to build more resilient and maintainable web services.

