Last 30 Days
No notifications
Good error handling makes APIs predictable and debuggable. Express uses a centralized error handler pattern.
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Email is required",
"statusCode": 400,
"details": [...] // Optional
}
}| Error | Code | Status | When |
| Validation | VALIDATION_ERROR | 400 | Bad input data |
| Authentication | AUTH_ERROR | 401 | Missing/invalid token |
| Authorization | FORBIDDEN | 403 | No permission |
| Not Found | NOT_FOUND | 404 | Resource doesn't exist |
| Conflict | CONFLICT | 409 | Duplicate entry |
| Server Error | INTERNAL_ERROR | 500 | Unexpected error |
Route Handler → throw error or next(error)
│
â–¼
Error Handler Middleware (err, req, res, next)
│
â–¼
Format & Send Error ResponseErrors will happen. The DB will be down, users will send garbage, third-party APIs will time out, and someone will eventually divide by zero. The job of an API is not to *prevent* every error — it is to catch them in one place, log them honestly, and respond consistently so the client knows what to do.
Three promises an API should keep:
1. Never crash the process because of an expected failure (a 404, a validation error). 2. Always reply with a JSON body in the same shape — never raw HTML, never an empty 500. 3. Never leak internals to the client (stack traces, SQL queries, file paths).
If you achieve those three, mobile and web clients can write one error handler and trust it forever.
This distinction changes how you respond:
Express recognises a middleware with four parameters as the error handler. Mount it last:
app.use((err, req, res, next) => {
const status = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
const isProd = process.env.NODE_ENV === 'production'; // Log everything server-side
req.log?.error({ err, status, code, path: req.path });
res.status(status).json({
error: {
code,
message: isProd && status === 500 ? 'Something went wrong' : err.message,
details: err.details,
requestId: req.id,
},
});
});
One handler. One response shape. Every route in your app benefits.
1. try/catch in every handler. Use an asyncHandler wrapper and let errors bubble to the central handler.
2. Returning 200 with { error: '...' }. Use the right HTTP status so res.ok works on the client.
3. res.send(err.message). Inconsistent shape, no error code, sometimes leaks stack traces.
4. res.send() then continuing to do work. Express does not stop at res.send; without return you can crash with "headers already sent".
5. Swallowing errors with empty catch. catch {} is how production bugs become invisible. At minimum, log.
6. Sending stack traces in production. Useful in dev, free recon for attackers in prod.
class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true;
Error.captureStackTrace?.(this, this.constructor);
}
}const NotFound = (resource = 'Resource') =>
new AppError(${resource} not found, 404, 'NOT_FOUND');
const Forbidden = (msg = 'Forbidden') =>
new AppError(msg, 403, 'FORBIDDEN');
const Conflict = (msg) =>
new AppError(msg, 409, 'CONFLICT');
// Use anywhere
if (!post) throw NotFound('Post');
if (post.userId !== req.user.sub) throw Forbidden();
const asyncH = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);app.get('/posts/:id', asyncH(async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) throw NotFound('Post');
res.json({ data: post });
}));
No try/catch needed. Errors travel automatically to the central handler.
Before the central handler, normalise common framework errors so clients see *your* codes, not Mongoose / Prisma / Zod internals:
app.use((err, req, res, next) => {
if (err.name === 'ValidationError') err = new AppError(err.message, 400, 'VALIDATION_FAILED', err.errors);
if (err.code === 11000) err = new AppError('Duplicate value', 409, 'DUPLICATE');
if (err.name === 'JsonWebTokenError') err = new AppError('Invalid token', 401, 'INVALID_TOKEN');
if (err.name === 'TokenExpiredError') err = new AppError('Token expired', 401, 'TOKEN_EXPIRED');
next(err);
});Mount this just before the error handler:
app.use((req, res, next) => next(NotFound(Route ${req.method} ${req.originalUrl})));
app.use(centralErrorHandler);Uncaught exceptions and unhandled promise rejections leave Node in an unknown state. The safe move is log + exit + let the orchestrator (PM2 / Docker / Kubernetes) restart you:
process.on('unhandledRejection', (reason) => {
log.fatal({ reason }, 'unhandledRejection');
shutdown(1);
});process.on('uncaughtException', (err) => {
log.fatal({ err }, 'uncaughtException');
shutdown(1);
});
function shutdown(code) {
server.close(() => process.exit(code));
setTimeout(() => process.exit(code), 10_000).unref(); // forced exit
}
Replace console.log with a real logger (pino, winston). Attach a request ID to every request and include it in the response:
const { randomUUID } = require('crypto');
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || randomUUID();
res.setHeader('X-Request-Id', req.id);
next();
});Now when a user complains "I got an error at 3:42 PM, request id req_abc", you can find every log line in seconds.
A standard JSON shape for HTTP errors. Worth knowing for public APIs:
{
"type": "https://api.example.com/problems/out-of-credit",
"title": "You do not have enough credit.",
"status": 403,
"detail": "Your current balance is 30, charge is 50.",
"instance": "/account/12345/transactions/abc"
}Use the Content-Type: application/problem+json header. Optional, but a great signal of API maturity.
Logs tell you *what*. To know *why*, add:
1. Build an AppError class + helpers (NotFound, Forbidden, Conflict).
2. Add an asyncH wrapper and use it on every route — remove all try/catch from handlers.
3. Add a central error handler that maps Zod / Mongoose / JWT errors to your codes and never leaks stacks in prod.
4. Add request IDs + pino logging and confirm an error log line shows the same id as the response header.