Async Error Handling with Promises/Async-Await · learncode.live

The Problem with Async in Express 4

Express 4 route handlers are synchronous by default - if an async function throws a rejected promise, Express does not catch it.

// ❌ This error is silently lost
app.get('/users', async (req, res) => {
  const users = await User.find(); // If this rejects...
  res.json(users);                 // Never executes
});

// The client hangs until timeout because no response is sent
// The server logs: UnhandledPromiseRejectionWarning
Client                    Express
  │                         │
  │── GET /api/users ──────→│ async handler starts
  │                         │   ─► await User.find() rejects
  │                         │      Promise rejected (unhandled)
  │                         │      No response sent!
  │                         │
  │←── (connection hangs) ──│ Client waits... times out... error

Solution 1: Try/Catch in Every Handler

The explicit approach:

app.get('/users', async (req, res, next) => {
  try {
    const users = await User.find();
    res.json(users);
  } catch (err) {
    next(err); // Pass to Express error handler
  }
});

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

Pros: Explicit, no magic, no dependencies. Cons: Boilerplate in every route.

Solution 2: Async Handler Wrapper

Extract the try/catch into a reusable wrapper:

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

module.exports = asyncHandler;

Usage

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

// Clean and DRY
app.get('/users', asyncHandler(async (req, res) => {
  const users = await User.find();
  res.json(users);
}));

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'Not found' });
  res.json(user);
}));

app.post('/users', asyncHandler(async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json(user);
}));

With Router-Level Application

const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();

// Apply to all routes in this router
router.get('/', asyncHandler(async (req, res) => { /* ... */ }));
router.post('/', asyncHandler(async (req, res) => { /* ... */ }));
router.put('/:id', asyncHandler(async (req, res) => { /* ... */ }));
router.delete('/:id', asyncHandler(async (req, res) => { /* ... */ }));

module.exports = router;

Solution 3: express-async-errors

Patch Express globally to catch async errors automatically:

npm install express-async-errors
require('express-async-errors'); // Must be at the very top!

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

// Now async errors are automatically caught!
app.get('/users', async (req, res) => {
  const users = await User.find();
  res.json(users);
});

app.get('/error', async (req, res) => {
  throw new Error('This is caught automatically!');
});

// Error handler still catches everything
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});

Pros: Zero boilerplate. Cons: Global side-effect, can hide issues.

Solution 4: Wrapping the Router

Apply asyncHandler to every route in a router automatically:

function asyncRouter(router) {
  const wrappedRouter = express.Router();

  router.stack.forEach((layer) => {
    if (layer.route) {
      const route = layer.route;
      Object.keys(route.methods).forEach((method) => {
        const handlers = route.stack.map((s) => s.handle);
        route[method] = undefined; // Clear original

        handlers.forEach((handler) => {
          route[method](asyncHandler(handler));
        });
      });
    }
  });

  return wrappedRouter;
}

// Usage
const router = express.Router();
router.get('/', async (req, res) => { /* ... */ });
router.post('/', async (req, res) => { /* ... */ });

app.use('/api', asyncRouter(router));

Handling Specific DB Errors

Mongoose

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);

  if (!user) {
    throw new NotFoundError('User');
  }

  res.json({ success: true, data: user });
}));

Prisma

app.get('/users/:id', asyncHandler(async (req, res) => {
  try {
    const user = await prisma.user.findUnique({
      where: { id: req.params.id }
    });

    if (!user) {
      throw new NotFoundError('User');
    }

    res.json({ success: true, data: user });
  } catch (err) {
    if (err.code === 'P2025') {
      throw new NotFoundError('User');
    }
    throw err;
  }
}));

Combining with Validation

const { body, validationResult } = require('express-validator');

app.post('/users',
  [
    body('name').trim().isLength({ min: 2 }),
    body('email').isEmail(),
    body('age').isInt({ min: 13 })
  ],
  asyncHandler(async (req, res) => {
    // Check validation
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      throw new AppError('Validation failed', 422,
        errors.array().map(e => ({ field: e.path, message: e.msg }))
      );
    }

    const user = await User.create(req.body);
    res.status(201).json({ success: true, data: user });
  })
);

Complete Setup

// index.js
require('express-async-errors');

const express = require('express');
const { AppError } = require('./errors/AppError');

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

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

// 404
app.all('*', (req, res) => {
  throw new AppError(`Route ${req.originalUrl} not found`, 404);
});

// Error handler
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const response = {
    success: false,
    error: err.isOperational ? err.message : 'Internal server error'
  };

  if (process.env.NODE_ENV === 'development') {
    response.stack = err.stack;
  }

  console.error(`[${new Date().toISOString()}] ${err.message}`);
  res.status(statusCode).json(response);
});

// async-error free routes
app.get('/users', async (req, res) => {
  const users = await User.find();
  res.json({ success: true, data: users });
});

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

Testing Async Error Handling

// Using Supertest
const request = require('supertest');

describe('GET /users/:id', () => {
  it('should return 404 for non-existent user', async () => {
    const res = await request(app)
      .get('/api/users/nonexistent-id');

    expect(res.status).toBe(404);
    expect(res.body.success).toBe(false);
    expect(res.body.error).toMatch(/not found/i);
  });

  it('should return 500 on DB failure', async () => {
    // Mock DB to fail
    jest.spyOn(User, 'findById').mockRejectedValue(new Error('DB down'));

    const res = await request(app)
      .get('/api/users/507f1f77bcf86cd799439011');

    expect(res.status).toBe(500);
    expect(res.body.success).toBe(false);
  });
});

Key Takeaways

  • Express 4 does not catch async errors - handle them explicitly
  • Use asyncHandler wrapper to DRY up try/catch in every route
  • Use express-async-errors for zero-boilerplate global async error catching
  • Always call next(err) or throw in wrapped code - never let a rejection go unhandled
  • Combine with centralized error handler for consistent responses
  • Test async error paths to ensure proper error propagation
  • For production, prefer the asyncHandler pattern over global patches
Courses