intermediate
backend

Error Handling in Express

Centralized error handling, custom error classes, and async error patterns

express fundamentals
24 min read

Centralized Error Handling in Express

Every route in a real API can fail: a database connection drops, a user sends malformed JSON, a record is missing, or a token has expired. If each handler invents its own way to respond to failure, clients receive inconsistent shapes, stack traces leak into production, and debugging becomes guesswork. The fix is a single, centralized error pipeline: route handlers throw or forward errors, and one dedicated middleware decides the HTTP status, the response body, and what gets logged. This lesson shows how to build that pipeline with custom error classes, an async wrapper, and an environment-aware error handler.

Learning Objectives
  • Design a custom AppError class hierarchy with status codes and error codes
  • Forward async errors to Express without try/catch in every route
  • Build a centralized error-handling middleware and a 404 catch-all
  • Map framework errors (validation, Prisma, JWT) to clean client responses
  • Return consistent JSON error shapes and avoid leaking stack traces

How Express Routes Errors

Express recognizes a middleware function as an error handler purely by its arity: it must take exactly four arguments (err, req, res, next). Whenever you call next(err) with a non-null argument, Express skips all remaining normal middleware and jumps straight to the first error handler. Because of this, error middleware must be registered after all routes and normal middleware.

javascript
import express from "express" const app = express() app.use(express.json()) // ...all your routes go here... // 404 handler — reached only if no route matched app.use((req, res, next) => { next(new AppError("Route not found", 404, "NOT_FOUND")) }) // Error handler — MUST be last and MUST have 4 params app.use((err, req, res, next) => { res.status(err.statusCode || 500).json({ success: false, error: { code: err.code || "INTERNAL_ERROR", message: err.message }, }) })

Custom Error Classes

Throwing plain Error objects loses information — there is no clean way to know whether a failure should return 400, 404, or 500. A small class hierarchy carries the HTTP status and a machine-readable code alongside the message. The isOperational flag distinguishes expected failures (bad input, missing record) from programmer bugs you may want to crash on.

javascript
class AppError extends Error { constructor(message, statusCode = 500, code = "INTERNAL_ERROR") { super(message) this.name = this.constructor.name this.statusCode = statusCode this.code = code this.isOperational = true Error.captureStackTrace(this, this.constructor) } } class NotFoundError extends AppError { constructor(resource = "Resource") { super(`${resource} not found`, 404, "NOT_FOUND") } } class ValidationError extends AppError { constructor(message, details) { super(message, 422, "VALIDATION_ERROR") this.details = details } } class UnauthorizedError extends AppError { constructor(message = "Authentication required") { super(message, 401, "UNAUTHORIZED") } }

Now a route can express intent directly, and the central handler already knows exactly what to send back:

javascript
app.get("/api/users/:id", asyncHandler(async (req, res) => { const user = await db.users.findById(req.params.id) if (!user) throw new NotFoundError("User") res.json({ success: true, data: user }) }))

Catching Async Errors

This is the most common source of hung requests. In Express 4, if an async route handler rejects, Express does not catch it automatically — the promise rejection is unhandled and the client waits forever. Wrapping every route in try/catch works but is noisy. A tiny higher-order function centralizes it:

javascript
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)

Any thrown or rejected error is passed to next, which routes it to your error middleware.

javascript
// Express 4: rejected promises are NOT auto-forwarded.
// Wrap the handler so .catch(next) fires the error pipeline.
app.post("/api/orders", asyncHandler(async (req, res) => {
const order = await orders.create(req.body) // may reject
res.status(201).json({ success: true, data: order })
}))

An Environment-Aware Error Handler

The production handler should do four things: normalize unknown errors to 500, log the real error server-side, hide stack traces from clients in production, and always return the same JSON envelope. Adding a requestId makes support tickets traceable back to a specific log line.

javascript
function errorHandler(err, req, res, next) { const statusCode = err.statusCode || 500 const isProd = process.env.NODE_ENV === "production" // Log full detail server-side (swap console for pino/winston in real apps) if (statusCode >= 500) { console.error(`[${req.id}] ${req.method} ${req.originalUrl}`, err) } res.status(statusCode).json({ success: false, error: { code: err.code || "INTERNAL_ERROR", message: err.isOperational ? err.message : "Something went wrong", ...(err.details ? { details: err.details } : {}), ...(isProd ? {} : { stack: err.stack }), }, }) } app.use(errorHandler)

Mapping Framework Errors

Third-party libraries throw their own error types. Translate them at the boundary so clients never see raw ORM or validation internals. A short lookup keeps the handler readable:

javascript
function normalizeError(err) { // Zod / express-validator style validation failures if (err.name === "ZodError") { return new ValidationError("Invalid request body", err.issues) } // Prisma unique-constraint violation if (err.code === "P2002") { return new AppError("Resource already exists", 409, "CONFLICT") } // Prisma record-not-found if (err.code === "P2025") { return new NotFoundError("Record") } // jsonwebtoken failures if (err.name === "TokenExpiredError") { return new UnauthorizedError("Token expired") } return err }

Call normalizeError at the top of your error handler so everything downstream deals only with AppError instances:

javascript
function errorHandler(err, req, res, next) { const error = normalizeError(err) const statusCode = error.statusCode || 500 res.status(statusCode).json({ success: false, error: { code: error.code || "INTERNAL_ERROR", message: error.message }, }) }

Best Practices & Common Pitfalls


Next Steps

Continue Learning
Add token-based auth to your Express API.

Next Lesson

JWT Authentication