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.
- 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.
javascriptimport 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 }, }) })
Warning
next(err) calls will fall through to Express's default HTML error page instead.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.
javascriptclass 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:
javascriptapp.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 }) }))
Keep codes stable
code string (NOT_FOUND, VALIDATION_ERROR) is a contract your frontend and mobile clients branch on. Human-readable message text can change freely; the machine code should not.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:
javascriptconst 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.
// 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 })
}))Warning
setTimeout, event emitters, or a detached .then() still need manual handling with try/catch and next(err).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.
javascriptfunction 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)
Why hide non-operational messages
500 usually means a bug — a null dereference, a bad query. Its raw message can expose table names, file paths, or library internals. Returning a generic "Something went wrong" for non-operational errors keeps that detail in your logs, not in the response body.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:
javascriptfunction 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:
javascriptfunction 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
Log, don't swallow
catch block that only returns a friendly message without logging the original error destroys your ability to debug production. Always log the real error server-side before responding.Next Steps
Next Lesson
JWT Authentication