Skip to main content

⚡ Asynchronous Error Handling

Almost every Express route talks to a database, a file system, or another API — and all of that work is asynchronous. Asynchronous errors don't follow the same path as synchronous ones, so the error middleware you built last lesson can miss them entirely. This lesson closes that gap for callbacks, promises, and async/await.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why Express 4 does not automatically catch errors from async handlers
  • Forward callback errors correctly with next(err) and modernize them with promisify
  • Write and apply a reusable catchAsync wrapper to eliminate repetitive try/catch
  • Use Express 5's built-in async error forwarding and know when to still reach for the wrapper
  • Organize errors with domain-specific classes and add global handlers for uncaught exceptions and unhandled rejections

Estimated Time: 35–45 minutes  •  Difficulty: Intermediate

Hands-on: Build a catchAsync utility, apply it to async routes, and prove it forwards a rejected database call to your error handler.

In This Lesson

The Challenge of Async Errors

Synchronous errors travel up the call stack and land in Express's error middleware. Asynchronous errors do not — by the time a database call rejects, the function that started it has already returned and its stack frame is gone. The error has nowhere to bubble to.

💡 A mental picture: A synchronous error is a ball dropped down a chute — gravity delivers it to the bottom. An async error is a ball thrown after the person holding the chute has already walked away. Nobody is there to catch it unless you arrange for a catcher in advance.

The consequences are concrete: in Express 4, an unhandled rejection in a route means the request hangs forever (the client eventually times out) and Node logs an UnhandledPromiseRejection warning that can crash the process on newer runtimes.

flowchart TB A[Client Request] --> B[Route Handler] B --> C[Synchronous Code] B --> D[Async Operation] C -->|throws| E[Error Middleware] D -->|rejects in Express 4| F[Uncaught: request hangs] E --> G[Clean Error Response] F --> H[Timeout / Process Warning]

Why Express Misses Them

Express 4's middleware system predates promises and async/await. Its try/catch wraps only the synchronous call to your handler. Look closely:

function handler(req, res, next) {
  throw new Error('sync');            // Caught — Express wraps this call

  setTimeout(() => {
    throw new Error('async');         // NOT caught — different tick, no stack
  }, 100);

  Promise.reject(new Error('promise')); // NOT caught — rejection, not a throw
}

An async function always returns a promise. When it rejects, there is no throw for Express 4 to intercept — the framework simply never learns the request failed. That is the gap every solution below fills: getting the rejection back to next().

📖 Key Term: forwarding

"Forwarding" an error means calling next(err). That single call is what hands control to your central error middleware. Every technique in this lesson is really just a different way of making sure next(err) gets called when an async operation fails.

Handling Callback Errors

Plenty of older libraries still use Node's error-first callback style. There is no magic here — you check the error and forward it yourself:

app.get('/users/:id', (req, res, next) => {
  db.getUser(req.params.id, (err, user) => {
    if (err) return next(err);                // Forward DB errors
    if (!user) return next(new NotFoundError('User'));
    res.json(user);
  });
});

Better still, stop writing callbacks. Node's built-in util.promisify converts any error-first callback function into one that returns a promise, so you can use it with async/await:

const { promisify } = require('node:util');

const getUser = promisify(db.getUser); // now returns a promise

app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await getUser(req.params.id);
    if (!user) return next(new NotFoundError('User'));
    res.json(user);
  } catch (err) {
    next(err);
  }
});

💡 promisify's contract

util.promisify works with any function whose last argument is a callback taking (error, result). It's the cleanest way to modernize legacy code — but notice we still need try/catch in every route. That repetition is exactly what the next section removes.

The catchAsync Wrapper

Writing try/catch in every async route is noise. The classic fix is a tiny higher-order function that wraps a handler, runs it, and pipes any rejection straight to next:

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

module.exports = catchAsync;

Promise.resolve() makes it work whether your handler returns a promise or not, and .catch(next) forwards any rejection. Now routes are clean:

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

app.get('/users/:id', catchAsync(async (req, res, next) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User');   // Rejection forwarded for you
  res.json(user);
}));

You can even wrap a whole router so every handler is covered automatically:

// utils/asyncRouter.js
const express = require('express');
const catchAsync = require('./catchAsync');

function asyncRouter() {
  const router = express.Router();
  for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
    const original = router[method].bind(router);
    router[method] = (path, ...handlers) =>
      original(path, ...handlers.map(h =>
        h.length === 4 ? h : catchAsync(h)   // leave error middleware alone
      ));
  }
  return router;
}

module.exports = asyncRouter;

⚠️ "Cannot set headers after they are sent"

If your handler already called res.json() and then something rejects, forwarding to next can trigger a second response and this error. Guard against it: check res.headersSent in your central handler and, if true, delegate to Express's default handler with next(err) instead of writing again.

flowchart LR A[async handler] --> B["catchAsync wraps it"] B --> C{Promise result} C -->|resolves| D[Response sent] C -->|rejects| E["next(err)"] E --> F[Central Error Middleware]

Express 5's Built-in Support

Here's the good news: Express 5 (the current major version, released in 2024) fixes this at the framework level. A rejected promise returned from an async handler is automatically forwarded to your error middleware — no wrapper required.

// Express 5 — this rejection reaches your error handler automatically
app.get('/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError('User');
  res.json(user);
});
ApproachExpress 4Express 5
Bare async handlerRejection lostAuto-forwarded ✅
catchAsync wrapperRecommendedOptional (still fine)
express-async-errors packageCommon patchNot needed

💡 So should I stop using catchAsync?

On Express 5 you no longer need it for correctness. Many teams keep it anyway because it's explicit, works identically across versions, and gives you one obvious place to add per-handler behavior later. Know both — you'll meet Express 4 codebases for years to come. (The old express-async-errors package solved this for Express 4 by monkey-patching the router; on Express 5 it's obsolete.)

Domain Error Classes

As an app grows, generic errors blur together. Organizing errors by domain — user, order, payment — keeps intent obvious and lets services throw meaningful types the handler can recognize:

// domains/user/errors.js
const { AppError } = require('../../errors');

class UserNotFoundError extends AppError {
  constructor(userId) {
    super(`User ${userId} not found`, 404, 'USER_NOT_FOUND');
  }
}

class UserValidationError extends AppError {
  constructor(details) {
    super('User validation failed', 400, 'USER_VALIDATION_FAILED');
    this.details = details;
  }
}

module.exports = { UserNotFoundError, UserValidationError };

The service layer throws them; the route stays thin; the central handler already knows what to do because they all extend AppError:

// domains/user/service.js
class UserService {
  async getUser(id) {
    const user = await db.getUser(id);
    if (!user) throw new UserNotFoundError(id);
    return user;
  }
}

// routes/userRoutes.js
router.get('/users/:id', catchAsync(async (req, res) => {
  const user = await userService.getUser(req.params.id);
  res.json(user);
}));

✅ The payoff of a shared base class

Because UserNotFoundError extends AppError, it already carries statusCode, code, and isOperational. Your central handler needs zero special cases — it formats every domain error identically. Consistency for free.

Global Safety Nets

Even with perfect route handling, something may reject where no catch can reach it — a stray timer, a background job, a bug. Node gives you two process-level events as a last resort. Treat them as a signal to log and restart, not to keep running in an unknown state.

// Synchronous bugs that escaped everything — the app is now unreliable
process.on('uncaughtException', (err) => {
  console.error('UNCAUGHT EXCEPTION! Shutting down.');
  console.error(err.name, err.message);
  process.exit(1); // let your process manager (pm2, Docker, etc.) restart
});

// A rejected promise nobody caught
process.on('unhandledRejection', (reason) => {
  console.error('UNHANDLED REJECTION! Shutting down.');
  console.error(reason);
  // Finish in-flight requests, then exit
  server.close(() => process.exit(1));
});

⚠️ These are a net, not a strategy

After an uncaught exception the process is in an undefined state — never "log and continue." Fix the real cause in your routes and services; let these handlers exist only so a crash is logged and cleanly restarted rather than silently hanging.

Hands-on Exercise

🏋️ Prove catchAsync Forwards a Rejection

Objective: Build the catchAsync wrapper, apply it to an async route backed by a fake database that rejects, and confirm the error reaches your central handler instead of hanging.

Instructions:

  1. Write catchAsync exactly as shown earlier.
  2. Create a fake async function getUser(id) that throws when id === '0' and resolves otherwise.
  3. Add GET /users/:id wrapped in catchAsync that awaits getUser.
  4. Add a central error handler that returns JSON with the status and message.
  5. Request /users/0 and confirm you get a clean JSON 500 (or your chosen code), not a hung request.
💡 Hint

Without the wrapper, an unhandled rejection would leave /users/0 hanging. With catchAsync, the .catch(next) forwards the thrown error into your final (err, req, res, next) handler.

✅ Solution
const express = require('express');
const app = express();

const catchAsync = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

async function getUser(id) {
  if (id === '0') throw Object.assign(new Error('DB connection lost'), { statusCode: 503 });
  return { id, name: 'Ada' };
}

app.get('/users/:id', catchAsync(async (req, res) => {
  const user = await getUser(req.params.id);
  res.json(user);
}));

app.use((err, req, res, next) => {
  console.error(err.message);
  res.status(err.statusCode || 500).json({
    success: false,
    error: { message: err.message }
  });
});

app.listen(3000, () => console.log('http://localhost:3000'));
// GET /users/0  -> 503 { "error": { "message": "DB connection lost" } }
// GET /users/7  -> 200 { "id": "7", "name": "Ada" }

🎯 Quick Quiz

Question 1: In Express 4, why is a rejected promise inside a bare async handler not caught by error middleware?

Question 2: What does catchAsync actually do?

Question 3: How should a process.on('uncaughtException') handler behave?

Summary & Quiz

🎉 Key Takeaways

  • Async errors escape Express 4 because a rejection is not a synchronous throw.
  • Forward callback errors with next(err); modernize legacy APIs with util.promisify.
  • A tiny catchAsync wrapper removes repetitive try/catch and forwards every rejection.
  • Express 5 forwards async rejections automatically — the wrapper becomes optional but still useful.
  • Domain error classes that extend AppError keep intent clear and reuse your central handler.
  • Add global handlers for uncaught exceptions and unhandled rejections — to log and restart, never to limp on.

📚 Further Reading

🚀 What's Next?

Your app now catches errors from every corner. Next we turn to error response strategies: designing a consistent JSON envelope, choosing the right status codes, and shaping responses that clients — and other developers — can rely on.

🎉 Great work!

No more silent hangs. Every async failure now flows to one predictable place.