Error Handling in Express
Centralize error responses with four-argument error middleware and let Express 5 handle async rejections automatically.
Recipe
Quick-reference recipe card - copy-paste ready.
import express, { type Request, type Response, type NextFunction } from "express";
class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public code?: string
) {
super(message);
}
}
const app = express();
app.get("/users/:id", async (req, res) => {
const user = await findUser(req.params.id);
if (!user) throw new AppError(404, "User not found", "USER_NOT_FOUND");
res.json(user);
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
const status = err instanceof AppError ? err.statusCode : 500;
const code = err instanceof AppError ? err.code : "INTERNAL_ERROR";
res.status(status).json({ error: err.message, code });
});When to reach for this: Any API that returns consistent error shapes instead of HTML stack traces or hung requests.
Working Example
import express, { type Request, type Response, type NextFunction } from "express";
import { z } from "zod";
class AppError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const app = express();
app.use(express.json());
function validateBody<T>(schema: z.ZodSchema<T>) {
return (req: Request, _res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
next(new AppError(422, result.error.message));
return;
}
req.body = result.data;
next();
};
}
app.post("/users", validateBody(createUserSchema), async (req, res) => {
const user = await createUser(req.body);
res.status(201).json(user);
});
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
const status = err instanceof AppError ? err.statusCode : 500;
res.status(status).json({
error: status === 500 ? "Internal server error" : err.message,
});
});
async function createUser(data: z.infer<typeof createUserSchema>) {
return { id: 1, ...data };
}What this demonstrates:
- Custom
AppErrorwith status codes - Validation middleware calling
next(err)instead of handling inline - Express 5 async handler throws forwarded to error middleware
- Production-safe 500 messages (no stack leak)
Deep Dive
How It Works
- Synchronous throws in route handlers are caught by Express
- Express 5 catches rejected promises from
asynchandlers automatically next(err)skips remaining middleware and jumps to the first 4-arg handler- Error middleware must be registered after all routes
Error Response Standards
| Status | Meaning | Example |
|---|---|---|
| 400 | Malformed request | Invalid JSON |
| 401 | Not authenticated | Missing token |
| 403 | Not authorized | Valid token, wrong role |
| 404 | Not found | Unknown resource ID |
| 422 | Validation failed | Zod schema rejection |
| 500 | Server error | Unhandled exception |
TypeScript Notes
// Typed error handler
const errorHandler: express.ErrorRequestHandler = (err, _req, res, _next) => {
res.status(500).json({ error: "Internal server error" });
};
app.use(errorHandler);Gotchas
- Forgot to register error middleware - Express default sends HTML error pages. Fix: add 4-arg handler at the end.
- Calling
res.json()thennext(err)- double response crash. Fix: return after sending, never callnextafter respond. next(err)in error middleware - passes to the next error handler; callingnext()without err skips to regular middleware. Fix: end the response in the final error handler.- Exposing
err.stackin production - information leak. Fix: log stack server-side, send generic message to client. - Not handling 404 separately - unknown routes fall through with no response. Fix: add catch-all 404 before error handler.
- Express 4 async without wrapper - unhandled rejections crash the process. Fix: upgrade to Express 5 or use try/catch.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
express-async-errors (Express 4) | Stuck on Express 4 | Already on Express 5 |
Fastify setErrorHandler | Fastify app with schema errors | Express codebase |
| NestJS exception filters | Decorator-based HTTP exceptions | Simple Express API |
| Result type (neverthrow) | Functional error handling in services | Team prefers throw/catch |
FAQs
Does Express 5 still need express-async-errors?
No. Express 5 natively forwards async errors to error middleware.
How do I handle operational vs programmer errors?
Operational errors (404, validation) get specific messages. Programmer errors (TypeError) log fully and return generic 500.
Should validation errors return 400 or 422?
Both are common. Pick one and document it in your API standards. 422 is popular for semantic validation failures.
How do I test error middleware?
Supertest: request(app).get("/missing").expect(404) and verify JSON error shape.
What about unhandled rejections outside routes?
Register process.on("unhandledRejection") to log and gracefully shut down. Do not rely on Express to catch those.
Can I have multiple error handlers?
Yes. Call next(err) in the first to pass to the next. The last handler should always send a response.
How does this integrate with OpenAPI?
Document error response schemas per status code. Error middleware should match those documented shapes.
Should I use res.sendStatus(500) in error handler?
Prefer res.status(500).json({...}) for APIs. sendStatus returns plain text.
Related
- Middleware Ordering - error handler placement
- Express 5 Migration - async error changes
- Security Middleware - don't leak errors
- Error Response Standards - API error shapes
- Express Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.