DEV Community

Cover image for Say Goodbye to Try-Catch: Smart Async Error Handling in Express πŸš€
Rashedin | FullStack Developer
Rashedin | FullStack Developer

Posted on • Edited on

Say Goodbye to Try-Catch: Smart Async Error Handling in Express πŸš€

πŸ“Œ This was originally posted on blog.rashedin.dev

When building a backend with Node.js and Express, we're likely using async/await to handle things like database queries or API calls.

But there’s a catch β€” if we don’t handle errors properly, our server can crash or behave unpredictably. 😬

In this post, you'll learn a clean, scalable way to handle async errors in Express:

  • Why try-catch in every route is painful
  • How to fix it with a reusable asyncHandler()
  • How to simplify this using external libraries
  • How to use my own package: express-error-toolkit
  • How to define custom error classes
  • And how to set up a global error handler

🚨 The Problem With Try-Catch Everywhere

Here’s how we usually handle errors:

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).json({ message: 'User not found' });
    res.json(user);
  } catch (error) {
    next(error);
  }
});
Enter fullscreen mode Exit fullscreen mode

Repeating this in every route is:

  • Redundant
  • Ugly
  • Easy to forget

Let’s fix that.


βœ… Option 1: Write a Custom asyncHandler

// utils/asyncHandler.js
const asyncHandler = (fn) => {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

module.exports = asyncHandler;
Enter fullscreen mode Exit fullscreen mode

Use it like this:

const asyncHandler = require('../utils/asyncHandler');

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new Error('User not found');
  res.json(user);
}));
Enter fullscreen mode Exit fullscreen mode

Clean. Reusable. No try-catch.


πŸ“¦ Option 2: Use a Library (Highly Recommended)

πŸ”Ή express-error-toolkit β€” View on npm

I built this package to make error handling in Express apps much easier. It includes:

  • An asyncHandler() function
  • Predefined error classes (NotFoundError, BadRequestError, etc.)
  • A global error-handling middleware
  • Clean stack traces in development

Install

npm install express-error-toolkit
Enter fullscreen mode Exit fullscreen mode

Use

const { asyncHandler } = require('express-error-toolkit');

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new Error('User not found');
  res.json(user);
}));
Enter fullscreen mode Exit fullscreen mode

🧱 Define Custom Error Classes

If you don’t use a package, you can define your own:

// utils/ApiError.js
class ApiError extends Error {
  constructor(statusCode, message) {
    super(message);
    this.statusCode = statusCode;
    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = ApiError;
Enter fullscreen mode Exit fullscreen mode

Usage:

const ApiError = require('../utils/ApiError');

if (!user) throw new ApiError(404, 'User not found');
Enter fullscreen mode Exit fullscreen mode

Or use express-error-toolkit’s built-in errors

const { NotFoundError } = require('express-error-toolkit');

if (!user) throw new NotFoundError('User not found');
Enter fullscreen mode Exit fullscreen mode

🌍 Global Error-Handling Middleware

Add this at the end of your middleware chain:

app.use((err, req, res, next) => {
  const status = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  res.status(status).json({
    success: false,
    message,
    stack: process.env.NODE_ENV === 'production' ? null : err.stack,
  });
});
Enter fullscreen mode Exit fullscreen mode

Or use express-error-toolkit’s built-in handler:

const { globalErrorHandler } = require('express-error-toolkit');

app.use(globalErrorHandler);
Enter fullscreen mode Exit fullscreen mode

πŸ§ͺ Full Example

const express = require('express');
const mongoose = require('mongoose');
const {
  NotFoundError,
  asyncHandler,
  globalErrorHandler,
} = require('express-error-toolkit');

const app = express();
app.use(express.json());

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User not found');
  res.json(user);
}));

app.use(globalErrorHandler);

app.listen(3000, () => console.log('Server running on port 3000'));
Enter fullscreen mode Exit fullscreen mode

🧠 Final Thoughts

βœ… Avoid try-catch in every route using asyncHandler

πŸ“¦ Use express-error-toolkit for a full-featured, clean setup

🧱 Throw meaningful errors with custom classes

🌍 Catch and format all errors in one global middleware

Follow this approach and your Express backend will be clean, scalable, and production-ready. πŸš€

Top comments (8)

Collapse
 
yogithesymbian profile image
Yogi Arif Widodo

i've alr handle just simple like these

/**
 * ERROR HANDLING
 * Error handling Middleware function for logging the error message
 * Fallback Middleware function for returning | 404 error for undefined paths
 * @param {Request Response Error Status} args default tx rx data
 * @param {req.my_func} options Options for diagnostics.
 * @returns {Response Error} Altered messages json array.
 * @private
 */

/* #TODO Move to errorHandling.js */
const errorLogger = (error, req, res, next) => {
  // winLogger.error(error.stack) /* #TODO REMOVE - already defined on returns */
  next(error) // calling next middleware
}
const errorResponder = (error, req, res, next) => {
  // res.header("Content-Type", "application/json");
  const status = error?.status || 400
  winLogger.info(`error handling general error: ${status}`)
  return response.error({
    req,
    res,
    error_response: error,
    db_model: req.my_func,
    error_code: status
  })
}
// Attach the first Error handling Middleware
// function defined above (which logs the error)
app.use(errorLogger)

// Attach the second Error handling Middleware
// function defined above (which sends back the res)
app.use(errorResponder)
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dev-rashedin profile image
Rashedin | FullStack Developer

Thanks for sharing your approach! πŸ™Œ
Your setup looks clean and nicely modular with the errorLogger and errorResponder pattern β€” I like that you're logging and handling separately.

If you're ever looking to simplify the async error handling part as well (without wrapping every route in try-catch), feel free to check out express-error-toolkit β€” it's a small package I built that does just that, plus it includes prebuilt error classes and a global handler. Could be a nice addition to your setup. πŸ˜„

Would love to hear more about how you're using req.my_func for diagnostics β€” that looks interesting!

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

very cool, the asyncHandler and error toolkit both make life so much easier for bigger projects
you think adding more specialized error types actually helps with debugging in production or just adds noise

Collapse
 
dev-rashedin profile image
Rashedin | FullStack Developer

Totally agree β€” once a project starts growing, these small abstractions make a big difference! πŸ™Œ

As for specialized error types β€” I’ve found they definitely help in production, as long as they’re used intentionally. For example:

  • NotFoundError, ValidationError, or AuthError help quickly categorize issues in logs or monitoring tools

  • They make it easier to return consistent error responses from the global handler

  • And in complex apps (e.g., with microservices or role-based logic), they help avoid relying on brittle if/else chains

That said, I try to keep the number of custom types lean β€” just enough to cover core use cases without becoming noise.

Curious to hear your thoughts β€” do you prefer fewer general errors or more specific ones?

Collapse
 
dotallio profile image
Dotallio

So much cleaner with asyncHandler! Have you ever hit any weird edge cases with this pattern in production?

Collapse
 
dev-rashedin profile image
Rashedin | FullStack Developer

Absolutely agree β€” asyncHandler really helps keep things clean! πŸ˜„

In my experience so far, it’s been quite reliable even in production environments. No major edge cases yet, especially when paired with a solid global error handler. That said, one thing to watch out for is throwing non-Error values (like plain strings or objects) β€” it’s always safer to throw an Error or a custom error class to keep the stack trace consistent.

Also, if you're using third-party middlewares that throw errors outside the async context, those might still need special attention.

Let me know if you've run into any tricky situations!

Collapse
 
laila_arjuman profile image
Laila Arjuman

I've tried express-async-error package before, I will try yours one for my future project.

Collapse
 
ru_stark_b33bfee90679d6e4 profile image
Ru Stark

Always tried an utils function to avoid try-catch, never heard of the package name you mentioned. It may increase my DX.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.