Skip to main content

How to handle errors in Express.js?

Express.js error handling - a mechanism that catches errors thrown in route handlers and routes them to dedicated error middleware, which sends a formatted response instead of crashing the process.

Theory

TL;DR

  • Synchronous errors in route handlers are caught by Express automatically
  • Async errors (promises, await) are NOT caught automatically - you must use try/catch and call next(err)
  • Error middleware has exactly 4 parameters (err, req, res, next) and must be registered last
  • The asyncHandler wrapper eliminates try/catch boilerplate across all async routes
  • In Node 15+, an unhandled promise rejection crashes the process

Quick example

js
// WRONG: async error bypasses Express error handling app.get('/user/:id', async (req, res) => { const user = await User.findById(req.params.id); // rejection is unhandled res.json(user); }); // RIGHT: catch and pass to error middleware app.get('/user/:id', async (req, res, next) => { try { const user = await User.findById(req.params.id); res.json(user); } catch (err) { next(err); // routes to error-handling middleware } }); // Error middleware - 4 params, registered last app.use((err, req, res, next) => { res.status(err.status || 500).json({ error: err.message }); });

Express sees the 4-parameter signature and treats this as an error handler, not regular middleware.

Sync vs async errors

Express wraps synchronous route handlers in an internal try/catch block. When you throw inside a sync handler, Express catches it and passes it down the error middleware chain.

Async handlers break this. When an async function is called, it immediately returns a Promise. Express invokes the handler, gets back a Promise, and moves on. If that Promise rejects later, Express is already done - the rejection happens outside its try/catch scope. Node.js emits an unhandledRejection event, which crashes the process in Node 15+.

That is the single biggest source of unhandled errors I've seen in Express codebases. One missing try/catch in a high-traffic route, and the server goes down.

When to use

  • Sync code in a route - throw freely, Express catches it
  • async/await in a route - wrap in try/catch, call next(err) in the catch block
  • Callbacks (legacy code) - call next(err) in the error path of the callback
  • Multiple async routes - use an asyncHandler wrapper to avoid repeating try/catch everywhere
  • Unmatched routes (404) - add a catch-all middleware after all routes, before the error handler

The asyncHandler pattern

Writing try/catch in every route gets repetitive fast. The asyncHandler wrapper solves this:

js
// Wrapper catches rejected promises and passes to next() const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; // No try/catch needed in the route itself app.get('/users/:id', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); if (!user) { const err = new Error('Not found'); err.status = 404; throw err; // wrapper catches this and calls next(err) } res.json(user); }));

The wrapper resolves the returned Promise and, if it rejects, passes the error directly to next. This is the same pattern used by express-async-errors and express-async-handler on npm.

Custom error classes

Attaching a status property to a plain Error works for small apps. Custom error classes make things more organized at scale:

js
class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.isOperational = true; // flag for expected errors Error.captureStackTrace(this, this.constructor); } } class NotFoundError extends AppError { constructor(resource = 'Resource') { super(`${resource} not found`, 404); } } class ValidationError extends AppError { constructor(message) { super(message, 400); } }

In error middleware, check err instanceof NotFoundError to handle different error types differently. Operational errors (bad input, missing resource) carry their own status codes. Unexpected errors default to 500.

How Express identifies error middleware

Express checks the .length property of a middleware function - the number of declared parameters. Regular middleware has 2 or 3. Error middleware has exactly 4. If you write (err, req, res) - 3 params - Express treats it as regular middleware and skips it entirely for errors.

That is why next must always be declared in error middleware, even if you never call it.

Common mistakes

Mistake 1: async handler without try/catch

js
// WRONG: rejection is unhandled, process crashes in Node 15+ app.get('/data', async (req, res) => { const data = await fetchData(); res.json(data); }); // RIGHT app.get('/data', async (req, res, next) => { try { const data = await fetchData(); res.json(data); } catch (err) { next(err); } });

Express only wraps the synchronous part of the handler. The async continuation runs after the handler has already returned.

Mistake 2: error middleware registered before routes

js
// WRONG: error handler registered first - never reached by route errors app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); }); app.get('/users', async (req, res, next) => { /* ... */ }); // RIGHT: routes first, error handler last app.get('/users', async (req, res, next) => { /* ... */ }); app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); });

Express processes middleware in registration order. An error handler registered before routes is never in the chain when those routes execute.

Mistake 3: calling next() before async work

js
// WRONG app.get('/user/:id', async (req, res, next) => { next(); // tells Express "done here" const user = await User.findById(req.params.id); // error here has no handler res.json(user); });

Once next() is called without an error argument, Express moves to the next middleware. Any error that happens after that has no handler waiting for it.

Mistake 4: sending response and passing error

js
// WRONG: both res.json() and next(err) can fire app.get('/user/:id', async (req, res, next) => { const user = await User.findById(req.params.id).catch(next); res.json(user); // runs even if .catch(next) already fired }); // RIGHT: use try/catch and do one or the other app.get('/user/:id', async (req, res, next) => { try { const user = await User.findById(req.params.id); if (!user) throw new NotFoundError('User'); res.json(user); } catch (err) { next(err); } });

Once res.json() or res.send() is called, the response is sent. Calling it again produces a "Cannot set headers after they are sent" error.

Mistake 5: treating all errors the same

js
// WRONG: everything becomes a 500 app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); }); // RIGHT: use status from the error object app.use((err, req, res, next) => { const status = err.statusCode || err.status || 500; const message = err.message || 'Internal server error'; if (!err.isOperational) { console.error('Unexpected error:', err); // log programmer errors } res.status(status).json({ error: { status, message }, ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); });

Operational errors (NotFoundError, ValidationError) carry their own status codes and are expected. Programmer errors should be logged and return 500. Treating them the same hides real bugs.

Real-world usage

  • Express apps - async routes need try/catch + next(err) or an asyncHandler wrapper on every handler
  • express-async-errors - npm package that patches Express to handle async errors automatically, no wrapper needed
  • NestJS - uses exception filters instead of middleware, same concept with different syntax
  • Fastify - built-in async error handling, no wrapper required
  • Koa - uses try/catch in middleware directly, no next(err) pattern

Follow-up questions

Q: Why doesn't Express catch errors in async handlers automatically?
A: Express wraps the synchronous part of the handler in try/catch. An async function returns a Promise immediately, so by the time the Promise rejects, the handler has already returned and the try/catch is gone. The rejection happens outside Express's scope entirely.

Q: What happens to unhandled promise rejections in Node 15+?
A: Node.js terminates the process. Before Node 15 it only emitted a deprecation warning. This is why an uncaught async error in Express is not just bad practice - it takes down production.

Q: Can you have multiple error-handling middleware functions?
A: Yes. Express calls them in order until one stops calling next(err). You can have specialized handlers for validation and database errors before a catch-all handler. Each must declare exactly 4 parameters.

Q: What happens if error middleware itself throws?
A: Express catches it and sends a generic 500 response. Error middleware should be defensive: check that err has expected properties, use defaults, never call external code without protection.

Q: How would you design error handling for a large app with multiple domains (auth, users, payments)?
A: Create domain-specific error classes - AuthenticationError, PaymentError, ValidationError - each with a statusCode property. Throw the right class in route handlers. In the global error handler, check instanceof to format responses differently: AuthenticationError returns 401 with no internal detail, ValidationError returns 400 with field-level messages, unexpected errors return 500 with a log entry. This separates formatting logic from business logic and makes every error path independently testable.

Examples

Basic: sync vs async error handling

js
const express = require('express'); const app = express(); // Sync: Express catches this automatically app.get('/sync', (req, res) => { throw new Error('Something went wrong'); // no try/catch needed }); // Async: must catch manually and pass to next app.get('/async', async (req, res, next) => { try { const data = await fetchSomething(); res.json(data); } catch (err) { next(err); // passes error to error middleware } }); // Error middleware - must be last, must have exactly 4 params app.use((err, req, res, next) => { res.status(err.status || 500).json({ error: err.message }); }); app.listen(3000);

A sync throw goes straight to error middleware. An async error without try/catch crashes the process in Node 15+.

Intermediate: production route with validation and database errors

js
app.post('/users', async (req, res, next) => { try { // operational error - bad input from client if (!req.body.email) { const err = new Error('Email is required'); err.status = 400; throw err; } const user = await User.create(req.body); res.status(201).json(user); } catch (err) { next(err); // all errors flow to the error handler } }); app.use((err, req, res, next) => { const status = err.status || 500; const message = err.message || 'Internal server error'; res.status(status).json({ error: { status, message }, // stack visible in development only ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); });

Validation errors return 400. Unexpected database errors return 500. Stack trace appears only in development.

Advanced: full setup with custom error classes, asyncHandler, and 404 handling

js
// Custom error classes class AppError extends Error { constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.isOperational = true; Error.captureStackTrace(this, this.constructor); } } class NotFoundError extends AppError { constructor(resource = 'Resource') { super(`${resource} not found`, 404); } } class ValidationError extends AppError { constructor(message) { super(message, 400); } } // asyncHandler wrapper - no try/catch in routes const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); // Route with no boilerplate app.get('/users/:id', asyncHandler(async (req, res) => { const user = await User.findById(req.params.id); if (!user) throw new NotFoundError('User'); // wrapper passes to next() res.json(user); })); // 404 catch-all - after all routes, before error handler app.use((req, res, next) => { next(new NotFoundError(`Route ${req.method} ${req.path}`)); }); // Global error handler - always last app.use((err, req, res, next) => { const status = err.statusCode || err.status || 500; if (!err.isOperational) { console.error('Unexpected error:', err); // log programmer errors } res.status(status).json({ error: err.message, ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }) }); });

isOperational marks errors you expect (bad input, missing records). Unexpected errors get logged with full stack before a generic 500 goes back to the client.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?