Episode 3 — NodeJS MongoDB Backend Architecture / 3.17 — Error Handling in Express

3.17.c — Async Handler Utility

The asyncHandler wrapper eliminates the need to write try-catch in every async route handler by automatically catching rejected promises and forwarding them to Express's error-handling middleware.


<< Previous: 3.17.b — Centralized Error Handling | Next: Exercise Questions >>


1. The Problem: Repetitive try-catch Blocks

In Express 4.x, every async route handler needs a try-catch block to properly forward errors. In a real application, this creates massive boilerplate:

// Without asyncHandler — every route needs try-catch

app.get('/api/users', async (req, res, next) => {
  try {
    const users = await User.find();
    res.json({ success: true, data: users });
  } catch (err) {
    next(err);
  }
});

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) throw new ApiError(404, 'User not found');
    res.json({ success: true, data: user });
  } catch (err) {
    next(err);
  }
});

app.post('/api/users', async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    res.status(201).json({ success: true, data: user });
  } catch (err) {
    next(err);
  }
});

app.put('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findByIdAndUpdate(req.params.id, req.body, {
      new: true,
      runValidators: true,
    });
    if (!user) throw new ApiError(404, 'User not found');
    res.json({ success: true, data: user });
  } catch (err) {
    next(err);
  }
});

app.delete('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);
    if (!user) throw new ApiError(404, 'User not found');
    res.status(204).json({ success: true, data: null });
  } catch (err) {
    next(err);
  }
});

Problems with This Approach

ProblemImpact
Every route has identical try { ... } catch (err) { next(err); }Cluttered, noisy code
Easy to forget try-catch in one handlerSilent failure: request hangs, no response sent
Easy to forget next(err) in the catch blockError is caught but never forwarded
Core route logic is buried inside try-catch nestingHarder to read and maintain
Cannot easily audit which routes have proper error handlingRisky for large codebases

2. The asyncHandler Utility Function

The solution is a higher-order function that wraps any async function and catches any rejected promise, forwarding it to next(err) automatically.

// utils/asyncHandler.js

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

module.exports = asyncHandler;

How It Works, Step by Step

1. asyncHandler receives your async route handler function (fn)
2. It returns a NEW function with the (req, res, next) signature
3. When Express calls this new function:
   a. It calls fn(req, res, next), which returns a Promise
   b. Promise.resolve() ensures it is always a Promise (even if fn is sync)
   c. .catch(next) attaches an error handler:
      - If the Promise resolves → normal response, nothing extra happens
      - If the Promise rejects → .catch(next) calls next(err) automatically
// What asyncHandler does internally (expanded for clarity):

const asyncHandler = (fn) => {
  return (req, res, next) => {
    const result = fn(req, res, next);        // Call the handler
    const promise = Promise.resolve(result);   // Ensure it is a Promise
    promise.catch((err) => next(err));         // Forward rejections to Express
  };
};

Alternative Name: catchAsync

Some codebases use catchAsync instead of asyncHandler. They are identical:

// Same function, different name
const catchAsync = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

3. Using asyncHandler in Route Handlers

Wrap each async handler with asyncHandler() and remove all try-catch blocks:

const asyncHandler = require('../utils/asyncHandler');
const ApiError = require('../utils/ApiError');
const User = require('../models/User');

// BEFORE: cluttered with try-catch
app.get('/api/users', async (req, res, next) => {
  try {
    const users = await User.find();
    res.json({ success: true, data: users });
  } catch (err) {
    next(err);
  }
});

// AFTER: clean, focused on business logic
app.get('/api/users', asyncHandler(async (req, res) => {
  const users = await User.find();
  res.json({ success: true, data: users });
}));

Full CRUD Example with asyncHandler

const express = require('express');
const router = express.Router();
const asyncHandler = require('../utils/asyncHandler');
const ApiError = require('../utils/ApiError');
const User = require('../models/User');

// GET all users
router.get('/', asyncHandler(async (req, res) => {
  const users = await User.find().select('-password');
  res.json({ success: true, count: users.length, data: users });
}));

// GET single user
router.get('/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id).select('-password');
  if (!user) {
    throw new ApiError(404, 'User not found');
  }
  res.json({ success: true, data: user });
}));

// CREATE user
router.post('/', asyncHandler(async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json({ success: true, data: user });
}));

// UPDATE user
router.put('/:id', asyncHandler(async (req, res) => {
  const user = await User.findByIdAndUpdate(req.params.id, req.body, {
    new: true,
    runValidators: true,
  });
  if (!user) {
    throw new ApiError(404, 'User not found');
  }
  res.json({ success: true, data: user });
}));

// DELETE user
router.delete('/:id', asyncHandler(async (req, res) => {
  const user = await User.findByIdAndDelete(req.params.id);
  if (!user) {
    throw new ApiError(404, 'User not found');
  }
  res.status(204).json({ success: true, data: null });
}));

module.exports = router;

Notice: no try-catch anywhere, no next parameter needed in the handler (unless you explicitly need it), and throw new ApiError(...) works naturally because asyncHandler catches it.


4. express-async-errors Package

If you prefer not to write even the asyncHandler wrapper, the express-async-errors package monkey-patches Express to automatically handle async errors.

npm install express-async-errors
// app.js — just require it once at the top
require('express-async-errors');
const express = require('express');
const app = express();

// Now async errors are automatically forwarded to error middleware!
app.get('/api/users', async (req, res) => {
  const users = await User.find(); // If this rejects, Express handles it
  res.json(users);
});

// No asyncHandler wrapper needed
// No try-catch needed
// Errors are automatically forwarded to error-handling middleware

asyncHandler vs express-async-errors

FeatureasyncHandler (manual)express-async-errors (package)
InstallationNo dependency, copy-paste utilitynpm install express-async-errors
UsageWrap each route: asyncHandler(fn)Require once: require('express-async-errors')
ExplicitnessClear which routes are wrappedImplicit, may confuse new developers
ControlYou choose which routes to wrapAll async routes are patched
Bundle size0 bytes (your own code)Tiny (~1KB)
Express 5 compatibilityWorks everywhereUnnecessary in Express 5
Team preferenceExplicit is better than implicitLess boilerplate wins

Recommendation: Use asyncHandler for explicitness and zero dependencies. Use express-async-errors if your team prefers minimal boilerplate and understands the implicit behavior.


5. Combining asyncHandler + ApiError + Centralized Error Handler

The three pieces work together as a complete error handling architecture:

ARCHITECTURE FLOW

  Route Handler (wrapped in asyncHandler)
       │
       ├── throw new ApiError(404, 'Not found')  ──┐
       │                                            │
       ├── await db.query() rejects                 ├── asyncHandler catches
       │                                            │   and calls next(err)
       ├── Mongoose CastError                       │
       │                                            │
       └── Any thrown/rejected error               ─┘
                                                    │
                                                    ▼
                                        Centralized Error Handler
                                              │
                                    ┌─────────┼──────────┐
                                    │         │          │
                              CastError  ApiError  ValidationError
                              → 400      → 404    → 400
                                    │         │          │
                                    └─────────┼──────────┘
                                              │
                                              ▼
                                     JSON Error Response

Complete Application Setup

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


// ----- utils/ApiError.js -----
class ApiError extends Error {
  constructor(statusCode, message, isOperational = true, stack = '') {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    if (stack) {
      this.stack = stack;
    } else {
      Error.captureStackTrace(this, this.constructor);
    }
  }
}
module.exports = ApiError;


// ----- middleware/errorHandler.js -----
const ApiError = require('../utils/ApiError');

const errorHandler = (err, req, res, next) => {
  let error = { ...err, message: err.message, stack: err.stack };

  // Mongoose bad ObjectId
  if (err.name === 'CastError') {
    error = new ApiError(400, `Invalid ${err.path}: ${err.value}`);
  }

  // Mongoose validation error
  if (err.name === 'ValidationError') {
    const messages = Object.values(err.errors).map((e) => e.message);
    error = new ApiError(400, `Validation failed: ${messages.join('. ')}`);
  }

  // MongoDB duplicate key
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    error = new ApiError(409, `Duplicate value for '${field}'. Use another value.`);
  }

  // JWT errors
  if (err.name === 'JsonWebTokenError') {
    error = new ApiError(401, 'Invalid token. Please log in again.');
  }
  if (err.name === 'TokenExpiredError') {
    error = new ApiError(401, 'Token expired. Please log in again.');
  }

  error.statusCode = error.statusCode || 500;

  // Log server errors
  if (error.statusCode >= 500) {
    console.error('ERROR:', err.stack);
  }

  // Development: full details
  if (process.env.NODE_ENV === 'development') {
    return res.status(error.statusCode).json({
      success: false,
      message: error.message,
      stack: err.stack,
      error: err,
    });
  }

  // Production: clean response
  if (error.isOperational) {
    return res.status(error.statusCode).json({
      success: false,
      message: error.message,
    });
  }

  return res.status(500).json({
    success: false,
    message: 'Something went wrong',
  });
};
module.exports = errorHandler;


// ----- routes/userRoutes.js -----
const express = require('express');
const router = express.Router();
const asyncHandler = require('../utils/asyncHandler');
const ApiError = require('../utils/ApiError');
const User = require('../models/User');

router.get('/', asyncHandler(async (req, res) => {
  const users = await User.find().select('-password');
  res.json({ success: true, count: users.length, data: users });
}));

router.get('/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id).select('-password');
  if (!user) throw new ApiError(404, 'User not found');
  res.json({ success: true, data: user });
}));

router.post('/', asyncHandler(async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json({ success: true, data: user });
}));

router.put('/:id', asyncHandler(async (req, res) => {
  const user = await User.findByIdAndUpdate(req.params.id, req.body, {
    new: true,
    runValidators: true,
  });
  if (!user) throw new ApiError(404, 'User not found');
  res.json({ success: true, data: user });
}));

router.delete('/:id', asyncHandler(async (req, res) => {
  const user = await User.findByIdAndDelete(req.params.id);
  if (!user) throw new ApiError(404, 'User not found');
  res.status(204).json({ success: true, data: null });
}));

module.exports = router;


// ----- app.js -----
const express = require('express');
const ApiError = require('./utils/ApiError');
const errorHandler = require('./middleware/errorHandler');
const userRoutes = require('./routes/userRoutes');

const app = express();

app.use(express.json({ limit: '10kb' }));

// Routes
app.use('/api/users', userRoutes);

// 404 for undefined routes
app.all('*', (req, res, next) => {
  next(new ApiError(404, `Cannot find ${req.originalUrl} on this server`));
});

// Centralized error handler
app.use(errorHandler);

module.exports = app;

6. Testing Error Handling

Verify that errors propagate correctly through the entire chain:

// __tests__/errorHandling.test.js
const request = require('supertest');
const app = require('../app');

describe('Error Handling', () => {
  // Test 404 for unknown routes
  it('should return 404 for unknown routes', async () => {
    const res = await request(app).get('/api/nonexistent');
    expect(res.status).toBe(404);
    expect(res.body.success).toBe(false);
    expect(res.body.message).toContain('Cannot find');
  });

  // Test invalid MongoDB ID format (CastError)
  it('should return 400 for invalid ID format', async () => {
    const res = await request(app).get('/api/users/not-a-valid-id');
    expect(res.status).toBe(400);
    expect(res.body.message).toContain('Invalid');
  });

  // Test resource not found (ApiError 404)
  it('should return 404 when user does not exist', async () => {
    const res = await request(app).get('/api/users/507f1f77bcf86cd799439011');
    expect(res.status).toBe(404);
    expect(res.body.message).toBe('User not found');
  });

  // Test validation error
  it('should return 400 for validation errors', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({}); // Missing required fields
    expect(res.status).toBe(400);
    expect(res.body.message).toContain('Validation failed');
  });

  // Test malformed JSON
  it('should return 400 for malformed JSON', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Content-Type', 'application/json')
      .send('{invalid}');
    expect(res.status).toBe(400);
  });

  // Test that error responses have consistent shape
  it('should return consistent error response shape', async () => {
    const res = await request(app).get('/api/nonexistent');
    expect(res.body).toHaveProperty('success', false);
    expect(res.body).toHaveProperty('message');
  });
});

7. asyncHandler for Middleware Too

asyncHandler is not limited to route handlers. You can wrap any async middleware:

// Async authentication middleware
const protect = asyncHandler(async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    throw new ApiError(401, 'Not authorized. No token provided.');
  }

  const decoded = jwt.verify(token, process.env.JWT_SECRET);
  // If token is invalid, jwt.verify throws JsonWebTokenError → caught by asyncHandler

  const user = await User.findById(decoded.id);
  if (!user) {
    throw new ApiError(401, 'User belonging to this token no longer exists.');
  }

  req.user = user;
  next(); // Everything OK, proceed to route handler
});

// Usage
router.get('/profile', protect, asyncHandler(async (req, res) => {
  res.json({ success: true, data: req.user });
}));

8. Production Checklist for Error Handling

Use this checklist to verify your error handling is production-ready:

ERROR HANDLING PRODUCTION CHECKLIST

  [ ] ApiError class created with statusCode, message, isOperational
  [ ] asyncHandler utility wraps ALL async route handlers
  [ ] asyncHandler utility wraps ALL async middleware (auth, etc.)
  [ ] Centralized errorHandler registered as LAST middleware
  [ ] errorHandler converts Mongoose CastError → 400
  [ ] errorHandler converts Mongoose ValidationError → 400
  [ ] errorHandler converts MongoDB duplicate key (11000) → 409
  [ ] errorHandler converts JWT JsonWebTokenError → 401
  [ ] errorHandler converts JWT TokenExpiredError → 401
  [ ] 404 catch-all route registered before errorHandler
  [ ] Development mode shows full stack traces
  [ ] Production mode hides stack traces and internal details
  [ ] Programmer errors (isOperational=false) show generic message in production
  [ ] Server errors (5xx) are logged with stack, URL, method, timestamp
  [ ] process.on('unhandledRejection') — graceful shutdown
  [ ] process.on('uncaughtException') — log and exit immediately
  [ ] No stack traces or internal paths leaked in production responses
  [ ] Error responses have consistent JSON shape: { success, message }
  [ ] All error scenarios tested with integration tests

9. Common Mistakes and Fixes

MistakeWhat HappensFix
Forgetting asyncHandler on one routeThat route's async errors are unhandled; request hangsWrap every async handler
Using asyncHandler but still writing try-catchUnnecessary boilerplateRemove try-catch; asyncHandler does it for you
throw inside callback (not async/await)asyncHandler cannot catch callback errorsConvert to async/await or use next(err) inside callback
Error handler with 3 parametersExpress treats it as regular middleware, never receives errorsAlways use (err, req, res, next) — all four
Error handler registered before routesErrors thrown in routes never reach the handlerRegister error handler AFTER all routes
Sending response AND calling next(err)Cannot set headers after they are sent crashUse return before next(err) or res.json()
Not setting NODE_ENV in productionDevelopment error details shown to usersSet NODE_ENV=production in your deployment

Key Takeaways

  1. asyncHandler wraps async functions and catches rejected promises, calling next(err) automatically
  2. The implementation is just one line: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
  3. It eliminates all try-catch boilerplate from async route handlers
  4. express-async-errors is an alternative that monkey-patches Express globally — no wrapping needed
  5. Combine asyncHandler + ApiError + centralized errorHandler for a complete error handling architecture
  6. asyncHandler works for middleware too, not just route handlers
  7. Thrown ApiError instances inside asyncHandler-wrapped functions automatically reach the centralized error handler
  8. Always test your error handling with integration tests to verify errors propagate correctly

Explain-It Challenge

Scenario: You are reviewing a pull request from a teammate. Their Express application has 40 async route handlers. None of them use asyncHandler or try-catch. The app has a centralized error handler at the bottom of app.js. Your teammate says "the error handler will catch everything."

Explain to your teammate why this is incorrect. Describe exactly what will happen when one of those async handlers throws an error. Then propose two different solutions (one using asyncHandler, one using express-async-errors) and explain the trade-offs. Finally, describe how you would write a test to prove that errors are being handled correctly.


<< Previous: 3.17.b — Centralized Error Handling | Next: Exercise Questions >>