Notifications

No notifications

/Phase 3

Error Handling

Centralized Error Handling

Good error handling makes APIs predictable and debuggable. Express uses a centralized error handler pattern.

Error Response Format

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "statusCode": 400,
    "details": [...]   // Optional
  }
}

Error Types

ErrorCodeStatusWhen
ValidationVALIDATION_ERROR400Bad input data
AuthenticationAUTH_ERROR401Missing/invalid token
AuthorizationFORBIDDEN403No permission
Not FoundNOT_FOUND404Resource doesn't exist
ConflictCONFLICT409Duplicate entry
Server ErrorINTERNAL_ERROR500Unexpected error

The Error Flow

Route Handler → throw error or next(error)
      │
      â–¼
Error Handler Middleware (err, req, res, next)
      │
      â–¼
Format & Send Error Response

On this page

Detailed Theory

Errors 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.

What "Good" Error Handling Looks Like

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.

Operational vs Programmer Errors

This distinction changes how you respond:

  • Operational = expected runtime conditions ("user not found", "payment declined", "DB timeout"). Recoverable. Reply with a 4xx or specific 5xx and keep the server running.
  • Programmer = bugs ("undefined is not a function", "undefined property of null"). Not recoverable safely — log loudly and, for uncaught process-level ones, restart the process.
Flag your own errors as operational so the central handler can tell them apart.

The Central Error Handler (Express)

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.

Beginner Mistakes to Skip

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.

Intermediate: A Reusable AppError

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();

Intermediate: asyncHandler Wrapper

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.

Intermediate: Mapping Library Errors

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);
});

Intermediate: 404 for Unknown Routes

Mount this just before the error handler:

app.use((req, res, next) => next(NotFound(Route ${req.method} ${req.originalUrl})));
app.use(centralErrorHandler);

Advanced: Process-Level Safety Net

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 }

Advanced: Structured Logging & Request IDs

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.

Advanced: Problem Details (RFC 7807)

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.

Advanced: Observability — Beyond Logs

Logs tell you *what*. To know *why*, add:

  • Metrics (Prometheus / OpenTelemetry): error rate per route, P95 latency.
  • Traces (OpenTelemetry / Datadog APM): see the full request path across services.
  • Alerts: page someone when 5xx rate > 1% for 5 minutes.
  • Sentry / error tracking: groups identical stack traces, links them to releases, tells you the first user affected.

Practice Path

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.