Skip to content

6. Error Handling

Error handling is where interviewers find out whether you have run something in production.


1. Operational vs Programmer Errors

OperationalProgrammer
WhatExpected failuresBugs
Examples404, validation failed, DB timeout, upstream 503undefined is not a function, bad logic
HandleYes — return a clear error to the clientNo — fix the code
Response4xx with a useful message500 with a generic message
Log levelwarn / infoerror / fatal

Why This Matters

You should not try to recover from a programmer error — the process may be in a corrupt state. Log it, return a generic 500, and fix the bug. Operational errors are part of normal life and deserve a proper response.

Naming this distinction is a strong opening to any error-handling question.


2. Custom Error Classes

js
export 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;

    Object.setPrototypeOf(this, new.target.prototype);   // fixes instanceof
    Error.captureStackTrace(this, this.constructor);
  }
}

export class NotFoundError extends AppError {
  constructor(resource = "Resource") {
    super(`${resource} not found`, 404, "NOT_FOUND");
  }
}

export class ValidationError extends AppError {
  constructor(issues) {
    super("Validation failed", 400, "VALIDATION_FAILED");
    this.issues = issues;
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = "Not authenticated") {
    super(message, 401, "UNAUTHORIZED");
  }
}

export class ForbiddenError extends AppError {
  constructor(message = "Forbidden") {
    super(message, 403, "FORBIDDEN");
  }
}

export class ConflictError extends AppError {
  constructor(message = "Resource conflict") {
    super(message, 409, "CONFLICT");
  }
}

The Two Lines Interviewers Look For

Object.setPrototypeOf — when compiling to ES5, extending a built-in like Error breaks the prototype chain and instanceof AppError returns false. This line restores it.

Error.captureStackTrace — removes the constructor frame from the stack, so the trace points at where the error was thrown, not at the class definition.


3. Central Error Handler

js
export function errorHandler(err, req, res, next) {
  if (res.headersSent) return next(err);   // delegate to Express's default

  let error = err;

  // Normalise known third-party errors
  if (err instanceof ZodError) {
    error = new ValidationError(err.flatten().fieldErrors);
  } else if (err.code === "P2002") {
    error = new ConflictError(`Duplicate value for ${err.meta?.target}`);
  } else if (err.code === "P2025") {
    error = new NotFoundError();
  } else if (err.name === "JsonWebTokenError") {
    error = new UnauthorizedError("Invalid token");
  } else if (err.name === "TokenExpiredError") {
    error = new UnauthorizedError("Token expired");
  } else if (!(err instanceof AppError)) {
    error = new AppError("Internal server error", 500);
    error.isOperational = false;
  }

  const log = error.isOperational ? req.log?.warn : req.log?.error;
  log?.({ err, statusCode: error.statusCode, url: req.originalUrl }, error.message);

  res.status(error.statusCode).json({
    error: {
      code: error.code,
      message: error.isOperational ? error.message : "Internal server error",
      ...(error.issues && { issues: error.issues }),
      ...(process.env.NODE_ENV !== "production" && { stack: error.stack }),
    },
    requestId: req.id,
  });
}

Four Details That Matter

  1. res.headersSent check — a second res.json() throws
  2. Normalise third-party errors in one place, so controllers stay clean
  3. Generic message for non-operational errors — never leak internals
  4. requestId in the response — the user can quote it in a support ticket and you can find the exact log line

Express Detects It By Arity

js
app.use((err, req, res, next) => { … });   // 4 params = error handler
app.use((req, res, next) => { … });        // 3 params = normal middleware

Drop the unused next and Express silently stops treating it as an error handler. Common and confusing bug.


4. Async Errors

Express 4 — Not Caught

js
// ❌ Request hangs forever. No response, no error, just a client timeout.
app.get("/users", async (req, res) => {
  const users = await db.query();   // throws
  res.json(users);
});

Express 4 only catches synchronous throws. A rejected promise from an async handler is never seen.

Three Fixes

js
// 1. Manual try/catch
app.get("/users", async (req, res, next) => {
  try {
    res.json(await db.query());
  } catch (err) {
    next(err);
  }
});

// 2. A wrapper — the common approach
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users", asyncHandler(async (req, res) => {
  res.json(await db.query());
}));

// 3. import "express-async-errors";  — patches Express globally

Express 5 Fixed It

Express 5 forwards rejected promises to the error handler automatically. No wrapper needed. Knowing which version you're on — and why the wrapper exists in older code — is the full answer.


5. Process-Level Handlers

js
process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "Uncaught exception");
  gracefulShutdown(1);
});

process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "Unhandled rejection");
  gracefulShutdown(1);
});

The Rule

These are a last-resort logging hook, not a recovery mechanism. After an uncaught exception the process state is unknown — a connection may be half-written, a lock may be held. Log it, flush, and exit. Your process manager restarts you clean.

Swallowing it and continuing is the wrong answer, and interviewers listen for it.

Since Node 15, an unhandled rejection terminates the process by default — the right change.

Graceful Shutdown

js
async function gracefulShutdown(code = 0) {
  server.close(async () => {          // stop accepting new connections
    try {
      await db.$disconnect();
      await redis.quit();
    } finally {
      process.exit(code);
    }
  });

  setTimeout(() => process.exit(1), 10_000).unref();   // force after 10s
}

process.on("SIGTERM", () => gracefulShutdown(0));
process.on("SIGINT", () => gracefulShutdown(0));

Required for zero-downtime deploys. Docker and Kubernetes send SIGTERM, wait, then SIGKILL. Without this handler every rolling restart drops in-flight requests. The .unref() on the timeout stops it from keeping the process alive on its own.


6. Errors in Controllers

js
export const getOrder = asyncHandler(async (req, res) => {
  const order = await db.order.findFirst({
    where: { id: req.params.id, userId: req.user.id },
  });

  if (!order) throw new NotFoundError("Order");   // handled centrally

  res.json({ data: order });
});

Throw a typed error and let the central handler format it. Controllers should not build error responses — that's how response shapes drift across endpoints.

Note the query scoped to userId — returning 404 rather than 403 for someone else's order avoids confirming it exists.


7. Retries and Circuit Breakers

For calls to services you don't control.

js
async function withRetry(fn, { retries = 3, baseDelay = 200 } = {}) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const retryable = err.status >= 500 || err.code === "ECONNRESET";
      if (!retryable || attempt === retries) throw err;

      const delay = baseDelay * 2 ** attempt + Math.random() * 100;
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

Three Things To Say

  1. Only retry retryable errors. Retrying a 400 just wastes time; retrying a 401 never succeeds.
  2. Exponential backoff with jitter. Fixed-interval retries from many clients create a thundering herd that keeps the struggling service down. The random jitter spreads them out.
  3. Idempotency. Retrying a POST that already succeeded can double-charge someone. Use an idempotency key.

Circuit Breaker

If a dependency is down, stop calling it — fail fast instead of holding connections open on every request.

States: closed (normal) → open (fail immediately after N failures) → half-open (let one request through to test recovery).

Libraries: opossum, or the pattern built into your service mesh.


8. Timeouts

Every outbound call needs one.

js
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
js
// Also: DB query timeouts, and a server-level request timeout
const pool = new Pool({ connectionTimeoutMillis: 5000, statement_timeout: 10_000 });
server.requestTimeout = 30_000;

Why It Matters

Without a timeout, a slow dependency holds your connections open. Requests queue, the pool exhausts, and your service goes down because someone else's did. A missing timeout turns their outage into your outage — that sentence is a good interview answer.


9. Structured Logging

js
import pino from "pino";

const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  redact: {
    paths: ["req.headers.authorization", "req.headers.cookie",
            "*.password", "*.passwordHash", "*.token", "*.creditCard"],
    censor: "[REDACTED]",
  },
});

req.log.error({ err, orderId, userId }, "Failed to process order");

Log Levels

LevelUse
fatalProcess is dying
errorProgrammer error, unexpected failure
warnOperational error — 4xx, retry, degraded dependency
infoRequest completed, service started
debugDevelopment detail

Three Rules

  1. JSON, not free text — you cannot query "user 123 failed" across a million lines of prose
  2. Redact secrets and PII — logs get shipped to third-party services and retained for months
  3. A request ID on every line, propagated to downstream services via a header, so one user's journey is traceable end to end

10. Error Handling Checklist

  • [ ] Custom AppError hierarchy with statusCode, code and isOperational
  • [ ] Object.setPrototypeOf in the base error constructor
  • [ ] One central error-handling middleware, registered last, with four parameters
  • [ ] Async handlers wrapped (Express 4) or Express 5 in use
  • [ ] Third-party errors normalised in one place
  • [ ] Generic 500 message in production; no stack traces in responses
  • [ ] uncaughtException and unhandledRejection log and exit
  • [ ] Graceful shutdown on SIGTERM / SIGINT
  • [ ] Timeouts on every database query and outbound HTTP call
  • [ ] Retries with exponential backoff and jitter, only for retryable errors
  • [ ] Structured JSON logs with redaction and a request ID
  • [ ] Error monitoring wired up (Sentry, Datadog) with source maps uploaded

The Interview Summary

"I separate operational errors from programmer errors. Operational ones get a typed error class and a proper 4xx. Programmer errors get logged with full context and a generic 500 — never a stack trace to the client. One central handler formats everything so the response shape is consistent. Process-level handlers log and exit rather than trying to recover, and graceful shutdown means a deploy doesn't drop in-flight requests."

© 2025 DDocs · Dipak's Documentation Guide