6. Error Handling
Error handling is where interviewers find out whether you have run something in production.
1. Operational vs Programmer Errors
| Operational | Programmer | |
|---|---|---|
| What | Expected failures | Bugs |
| Examples | 404, validation failed, DB timeout, upstream 503 | undefined is not a function, bad logic |
| Handle | Yes — return a clear error to the client | No — fix the code |
| Response | 4xx with a useful message | 500 with a generic message |
| Log level | warn / info | error / 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
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
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
res.headersSentcheck — a secondres.json()throws- Normalise third-party errors in one place, so controllers stay clean
- Generic message for non-operational errors — never leak internals
requestIdin the response — the user can quote it in a support ticket and you can find the exact log line
Express Detects It By Arity
app.use((err, req, res, next) => { … }); // 4 params = error handler
app.use((req, res, next) => { … }); // 3 params = normal middlewareDrop the unused next and Express silently stops treating it as an error handler. Common and confusing bug.
4. Async Errors
Express 4 — Not Caught
// ❌ 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
// 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 globallyExpress 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
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
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
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.
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
- Only retry retryable errors. Retrying a 400 just wastes time; retrying a 401 never succeeds.
- 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.
- 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.
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });// 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
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
| Level | Use |
|---|---|
fatal | Process is dying |
error | Programmer error, unexpected failure |
warn | Operational error — 4xx, retry, degraded dependency |
info | Request completed, service started |
debug | Development detail |
Three Rules
- JSON, not free text — you cannot query "user 123 failed" across a million lines of prose
- Redact secrets and PII — logs get shipped to third-party services and retained for months
- 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
AppErrorhierarchy withstatusCode,codeandisOperational - [ ]
Object.setPrototypeOfin 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
- [ ]
uncaughtExceptionandunhandledRejectionlog 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."