Backend in Depth
Error Handling: Operational vs Programmer Errors
One central error handler, typed error classes, how Express 5 treats rejected promises, and when to let the process crash and restart.
On this page
In one line: keep two kinds of errors apart. Operational errors happen because the world misbehaves (bad input, a timeout, a missing row). Programmer errors are bugs. Handle the first kind calmly and in the same way every time. For the second kind, log it loudly and let the process restart. And never lie to the client about which one happened.
An error is a value that describes something that went wrong while the program was running. Error handling is the code that decides what to do next. A backend is the server-side program that answers requests from clients such as browsers and mobile apps.
Error handling shows how mature backend code is. A frontend developer is used to try/catch around a fetch. A backend must decide, for every failure, what the client sees, what the logs record, and whether the process can still be trusted.
Words used in this lesson
try/catchis a statement that runs some code and, if that code throws an error, jumps to a block that handles it. Example:try { JSON.parse(text) } catch (e) { ... }.- A stack trace is the list of function calls that were active when an error was created. It shows where the error came from.
- An HTTP status code is a three-digit number in a response that tells the client the result.
4xxmeans the client made a mistake.5xxmeans the server failed. - Middleware is a function in Express that sits between the request and the final handler. It can read the request, change it, send a response, or pass control on by calling
next(). - A promise is an object that stands for a result that will arrive later. It can finish with a value (resolved) or with an error (rejected). An unhandled rejection is a rejected promise that no code handles.
- A process manager (or supervisor) is a tool that starts your app and starts it again if it stops. Examples: PM2, systemd, Kubernetes.
- Idempotent describes an operation that gives the same result if you repeat it. Setting a name to "Ana" is idempotent. Adding 1 to a counter is not.
- A request ID (or correlation ID) is a unique value given to one request. You put it in every log line and in the response. It lets you find all the logs of that request.
- Exponential backoff is a retry plan where the wait time doubles after each failure (1 s, 2 s, 4 s...). Jitter is a small random amount added to each wait.
- A circuit breaker is a wrapper that stops calling a failing service for a while. Like the breaker in a house, it opens when there is trouble, so the damage does not spread.
- A graceful shutdown means the app stops in an orderly way. It stops taking new requests, finishes the current ones, closes its connections, and then exits.
- A repository is a module that holds all the database code for one kind of data (for example users). The rest of the app asks it for data and does not write queries itself.
Two kinds of errors
| Operational error | Programmer error | |
|---|---|---|
| What it is | A problem at run time in a correct program | A bug in the code |
| Examples | Invalid input, record not found, database connection refused, timeout from another service, disk full, rate limit exceeded | undefined is not a function, reading a property of null, wrong argument type, an off-by-one mistake, forgetting to await |
| Expected? | Yes: it will happen sooner or later | No: it should never happen |
| Recoverable? | Often: retry, use a fallback, tell the user | Not safely: the process may be in an unknown state |
| Right response | Handle it, map it to an HTTP status, log at warn/info | Log at error/fatal with the stack, return a generic 500, restart the process |
A log level is a label (such as info, warn, error, fatal) that says how serious a log line is. Lesson 05 explains them.
Why does this matter? Say you catch a programmer error and carry on. Your program keeps running with data that may be broken (half-updated objects, locks that are never released). The Node.js documentation advises the same for an uncaught exception: log it and restart the process under a process manager (a tool that restarts your app when it stops).
Typed errors
A typed error is an error that has its own class (a blueprint for objects) with fixed fields such as a status and a code. Use classes, so the rest of the system can tell errors apart without reading the message text:
export class AppError extends Error { readonly isOperational = true; constructor( message: string, readonly status: number, readonly code: string, options?: { cause?: unknown; details?: unknown }, ) { super(message, { cause: options?.cause }); this.name = new.target.name; this.details = options?.details; Error.captureStackTrace?.(this, new.target); } details?: unknown;}export class ValidationError extends AppError { constructor(msg: string, details?: unknown) { super(msg, 422, "VALIDATION_FAILED", { details }); } }export class UnauthorizedError extends AppError { constructor(msg = "Authentication required") { super(msg, 401, "UNAUTHORIZED"); } }export class ForbiddenError extends AppError { constructor(msg = "Not allowed") { super(msg, 403, "FORBIDDEN"); } }export class NotFoundError extends AppError { constructor(what = "Resource") { super(`${what} not found`, 404, "NOT_FOUND"); } }export class ConflictError extends AppError { constructor(msg: string, code = "CONFLICT") { super(msg, 409, code); } }export class TooManyRequestsError extends AppError { constructor(readonly retryAfterSeconds: number) { super("Too many requests", 429, "RATE_LIMITED"); } }Notes:
- The
causeoption is a standard field onError(Node 16.9 and later). It keeps the original error attached when you wrap it. The logs then show the whole chain. For example: "Could not create order", caused by "unique constraint failed". - Domain code throws
NotFoundError. It does not callres.status(404). Only the HTTP layer knows about status codes (Lesson 01).
One central error handler
A central error handler is one function that receives every error and builds the response. Do not scatter res.status(...).json(...) in every controller. Throw the error, and turn it into a response in one place:
import type { ErrorRequestHandler } from "express";import { AppError, TooManyRequestsError } from "../shared/errors";import { logger } from "../lib/logger";export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => { // If headers already went out (streaming), delegate to Express' default handler if (res.headersSent) return _next(err); const requestId = res.getHeader("x-request-id"); // set by the requestContext middleware in Lesson 05 if (err instanceof AppError) { logger.warn({ err, requestId, code: err.code }, "operational error"); if (err instanceof TooManyRequestsError) res.set("Retry-After", String(err.retryAfterSeconds)); return res.status(err.status).type("application/problem+json").json({ type: `https://api.example.com/errors/${err.code.toLowerCase()}`, title: err.message, status: err.status, code: err.code, requestId, ...(err.details ? { errors: err.details } : {}), }); } // Anything else is unexpected: log everything, tell the client nothing logger.error({ err, requestId, path: req.path }, "unhandled error"); res.status(500).type("application/problem+json").json({ type: "https://api.example.com/errors/internal", title: "Something went wrong", status: 500, code: "INTERNAL", requestId, // lets the user quote it to support });};The response format application/problem+json is the standard "Problem Details" format for HTTP errors (RFC 9457). It gives a fixed set of fields (type, title, status, and more), so every error looks the same.
Register it last (app.use(errorHandler) after all routes). An error-handling middleware has four parameters. Express knows it is an error handler because of that number (this is called its "arity", the number of parameters a function takes).
Never send err.stack, SQL text, file paths or the internal message of an unexpected error to the client. That leaks information, and it gives an attacker free clues.
Async errors: Express 5 vs Express 4
Interviewers often ask about this.
- Express 5: if a route handler or middleware returns a promise, Express sends a rejection to
next(err)for you. This also works when anasyncfunction throws. You can write handlers in a natural way:
// Express 5: works as written. The thrown NotFoundError reaches errorHandler.app.get("/orders/:id", async (req, res) => { const order = await orders.get(req.params.id); // throws NotFoundError res.json(order);});- Express 4: Express does not catch a rejected promise inside an
asynchandler. It becomes an unhandled rejection (a failed promise that nothing handles). The client never gets a response. On current Node versions, an unhandled rejection with no handler also stops the process. So you must wrap handlers:
// Express 4 onlyconst asyncHandler = (fn: RequestHandler): RequestHandler => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);app.get("/orders/:id", asyncHandler(async (req, res) => { /* ... */ }));You can also use the express-async-errors patch. Both versions already caught normal (synchronous) throws inside a handler. Only promises are different. If you are on Express 4, plan to upgrade.
Process-level safety nets
A process-level handler is a function you attach to the whole Node.js process, not to one request. It is your last chance to log a problem. Some errors escape every try/catch. Examples are a rejected promise that nobody awaited, and an exception inside an event callback. An uncaughtException is an error thrown that no try/catch caught. SIGTERM is a signal the operating system sends to ask a program to stop politely.
process.on("unhandledRejection", (reason) => { logger.fatal({ err: reason }, "unhandledRejection"); shutdown(1);});process.on("uncaughtException", (err) => { logger.fatal({ err }, "uncaughtException"); shutdown(1);});function shutdown(code: number) { // Stop accepting new connections, finish in-flight requests, close DB; then exit server.close(() => prisma.$disconnect().finally(() => process.exit(code))); setTimeout(() => process.exit(code), 10_000).unref(); // hard deadline}process.on("SIGTERM", () => shutdown(0)); // sent by Kubernetes/ECS/systemd on deployShould you catch uncaughtException and keep running? No. After an uncaught exception, the process is in an undefined state. Log it and exit. Let a supervisor (PM2, systemd, Kubernetes, ECS) start a fresh instance. This works well only if you run more than one instance behind a load balancer (a server that shares traffic between instances). You also need a graceful shutdown (finish the current requests before exiting).
Mapping infrastructure errors
A domain error is an error in the language of your business (NotFoundError, ConflictError), not in the language of a library. Turn low-level failures into domain errors at the boundary. This is usually the repository. The example uses Prisma, a library that lets Node.js code talk to a database:
import { Prisma } from "../generated/prisma/client"; // path depends on the generator `output` (Prisma 7)export async function createUser(data: { email: string; name: string }) { try { return await prisma.user.create({ data }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") { throw new ConflictError("Email already registered", "EMAIL_TAKEN"); // unique constraint failed } throw e; // unknown: let it bubble as an unexpected error }}export async function renameUser(id: string, name: string) { try { return await prisma.user.update({ where: { id }, data: { name } }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { throw new NotFoundError("User"); // update or delete found no matching record } throw e; }}- A unique violation →
409. A foreign-key violation → usually422/409. Not found →404. - A timeout or connection error from a dependency is operational, but it is not the client's fault. Use
502/503/504, ideally withRetry-After(an HTTP header that tells the client how many seconds to wait before trying again). If the operation is idempotent (safe to repeat), also retry it inside your server. - Wrap the error and add context, but keep the cause:
throw new AppError("Could not charge card", 502, "PAYMENT_UNAVAILABLE", { cause: e }).
Retries, timeouts and not hanging forever
- A timeout is a time limit after which a call is given up. Always set timeouts on calls that go out (HTTP, database, queue). With no timeout, one slow dependency can use up all your connections everywhere.
- Retry only short-lived failures (network blips,
429,503). Retry only idempotent operations. Use exponential backoff and jitter (wait longer after each try, with a small random change, so clients do not all retry together) (Practical Design Problems, Lesson 07). - Use a circuit breaker around unreliable dependencies. It stops calls for a while after repeated failures, so you fail fast and requests do not pile up.
Promise.allis a function that waits for many promises and rejects as soon as one rejects. The other promises keep running, but you no longer get their results.Promise.allSettledwaits for all of them and reports each result asfulfilledorrejected. Use it when partial success is acceptable. Difference in one line:allstops at the first failure,allSettlednever stops early.
const results = await Promise.allSettled([sendEmail(), updateCrm(), notifySlack()]);for (const r of results) if (r.status === "rejected") logger.warn({ err: r.reason }, "non-critical side effect failed");Common mistakes
- Swallowing errors (
catch (e) {}), or logging them and then carrying on as if nothing happened. - Leaking internals (stack traces, SQL, file paths) to clients.
- Returning
200on failure, or500for mistakes made by the client. - Different error shapes in different endpoints.
- Using
throw "string"or throwing values that are notErrorobjects. They lose the stack trace. - Catching and rethrowing without the cause. This hides the root problem.
- Handling a programmer error and carrying on, instead of crashing cleanly.
- No timeouts on outgoing calls, and no graceful shutdown.
- Logging the same error at every layer. This makes noise. Log once, where you handle it.
Interview questions
Operational vs programmer error?
Operational errors are expected problems at run time in a correct program: bad input, a missing record, timeouts, connection failures. I handle them, map them to the right status codes, and often retry. Programmer errors are bugs, like a null dereference or a wrong type. I cannot recover from them safely. So I log them with full detail and restart the process. This difference decides whether I handle the error or crash.
How does Express 5 handle a rejected async handler?
Express 5 sees that the handler returned a promise. If the promise rejects, or an async function throws, Express calls next(err) automatically. The error then goes to your error-handling middleware. In Express 4, the rejection is unhandled. The client gets no response, and on current Node it can crash the process. So Express 4 needs an async wrapper.
Should you catch uncaughtException and keep running?
No. The process may be in a broken state. I log the error and start a graceful shutdown: stop accepting connections, let the current requests finish, and close resources. Then I exit. A process manager or orchestrator starts a clean instance. I run several instances, so users do not notice.
Scenario: errors leak stack traces to clients. Fix it.
I add one central error-handling middleware. It treats known AppErrors differently from unexpected errors. Known errors become a status, a stable code and a safe message. Unexpected errors become a generic 500 with a request ID. I log the full error with its stack on the server. I set NODE_ENV=production and remove any code like res.send(err). Then I add a test that checks no stack trace appears in responses.
How do you design error classes?
I make a base AppError that carries status, code, a safe message, optional details and the original cause. Subclasses extend it (NotFoundError, ConflictError, ValidationError). Domain and repository code throw them. One central handler turns them into the HTTP response. Clients switch on code, never on the message text.
Why log the error once?
If you log at every layer on the way up, you get duplicate entries. These hide the real count and the real cause. I log where the error is handled, which is usually the central handler. I include full context and the correlation ID. The layers in between add context through cause instead.
When do you retry a failed operation?
Only when the failure is temporary (timeouts, connection resets, 429, 503) and the operation is idempotent or protected by an idempotency key. I use a limited number of attempts with exponential backoff and jitter. I respect Retry-After and set timeouts. I also consider a circuit breaker, so retries do not make an outage worse.
What does a graceful shutdown involve?
When SIGTERM arrives, I stop accepting new connections and make the readiness check fail (a readiness check is a URL that the platform calls to ask "can you take traffic now?"). I let the current requests finish within a time limit. I close the database and queue connections and flush the logs. Then I exit. A hard timeout forces the exit if something hangs. This avoids dropped requests during deploys and restarts.
Key takeaways
- Operational errors: handle and report. Programmer errors: log and restart.
- Use typed errors with
status, a stablecodeandcause. Throw domain errors, and turn them into HTTP in one central handler. - Express 5 forwards async rejections automatically. Express 4 needs a wrapper.
- Never leak internals. Return a request ID instead.
- Set timeouts. Retry only idempotent, short-lived failures, with backoff and jitter. Shut down gracefully.
What's next
Lesson 05: Logging and Observability. You cannot fix errors you cannot see. Next: structured logs, correlation IDs, metrics and what you should never log.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Request and Response Design: JSON Shapes, Errors and Validation · API Design
- Node.js & Express Interview Questions · Full-Stack Questions
- Errors, Proxy, Reflect, Copying and Immutability · JavaScript Part 2
Related series