Error Handling in APIs

Catching errors in one central place and returning consistent, safe responses, instead of letting the server crash or leak internal details to callers.

What is it?

Things go wrong constantly in a running backend: a database might be temporarily unreachable, a client might send malformed data, a bug might cause an unexpected exception deep inside some function. If nothing catches these problems, a few bad outcomes can happen — the whole server process can crash, taking down every other request it was handling too, or the raw error (possibly including a stack trace or internal file paths) can leak straight back to whoever made the request, which is both unhelpful and a security risk.

Centralized error handling means having one place — usually a special piece of middleware — that catches errors from anywhere in the app and turns them into a consistent, safe response shape, so every route doesn't need to duplicate that logic itself.

Explain like I'm 10

It's like having one dedicated customer service desk at the back of a large store, instead of expecting every single cashier to personally know how to handle every possible complaint. Whatever goes wrong anywhere in the store, it gets routed to that one desk, which knows how to respond consistently and politely, without exposing the store's internal problems to the customer.

Examples

A route that forwards its errors instead of crashing

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.users.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }
    res.json(user);
  } catch (err) {
    next(err); // hand off to centralized error-handling middleware
  }
});

Rather than letting a thrown database error crash the process, the route catches it and passes it along to a dedicated error handler.

Centralized error-handling middleware

app.use((err, req, res, next) => {
  console.error(err); // log the full details internally
  res.status(err.statusCode || 500).json({
    error: "Something went wrong. Please try again.",
  });
});

This special four-argument middleware is Express's designated way to catch errors passed via next(err) from anywhere in the app, logging the full detail internally while sending a safe, generic message to the caller.

How it works

Frameworks like Express recognize a middleware function by its number of arguments: a normal middleware takes (req, res, next), while an error-handling one takes (err, req, res, next) — four arguments. When any route or middleware calls next(err) (passing something to next), Express skips ahead past all remaining normal middleware and routes, straight to the nearest error-handling middleware. That's where you decide what to log internally and what safe message to send back.

Why does it exist?

Without centralized handling, every single route would need its own copy-pasted logic for catching errors, deciding on status codes, and avoiding leaking internal details — and it's easy to forget one spot, leaving a crash or a leak waiting to happen. Centralizing it guarantees one consistent policy applies everywhere, and makes it much harder to accidentally expose a stack trace to a real user.

When to use it

Add centralized error handling to essentially every real backend project, from the start — it's cheap to set up early and expensive to retrofit once dozens of routes already handle errors inconsistently.

When not to use it

It doesn't replace handling expected failure cases close to where they happen — a missing record (404) or invalid input (400) usually deserves its own specific, immediate response rather than being funneled through the generic error handler meant for unexpected failures.

Common mistakes

  • Sending the raw error object (including its stack trace) directly to the client in a production response.

  • Forgetting to call next(err) inside an async route, so a thrown error is never caught and the process may crash.

  • Treating every failure the same way instead of distinguishing expected failures (bad input, missing resource) from truly unexpected ones (a bug, a downed dependency).

Practice exercises

  1. Easy:

    Write a route that catches a thrown error and forwards it to next(err) instead of letting it crash the server.

  2. Medium:

    Write centralized error-handling middleware that logs the full error internally but returns only a generic message and status code to the client.

  3. Hard:

    Design an error-handling scheme that distinguishes 'expected' errors (like validation failures, with a custom statusCode) from truly unexpected ones, and returns different levels of detail for each.

Interview questions

Why is centralized error handling important in an API?

It ensures errors are handled consistently everywhere, prevents unhandled exceptions from crashing the server, and avoids leaking sensitive internal details like stack traces to clients.

How does Express know a middleware function is meant for error handling?

By its signature — an error-handling middleware takes four arguments, (err, req, res, next), instead of the usual three.

What's the difference between an expected error and an unexpected one in API design?

An expected error (like invalid input or a missing resource) is handled explicitly with a clear status and message; an unexpected error (a bug or crash) is caught generically, logged in detail, and reported to the client with a safe, non-revealing message.

What happens if an `async` route handler throws in Express 4, but nothing catches it or forwards it with `next(err)`?

Express 4 doesn't automatically catch a rejected promise returned by an async handler — it becomes an unhandled promise rejection that Express never sees, which can crash the process or silently leave the request hanging. (Express 5 fixes this by automatically forwarding thrown errors from async handlers to the error middleware.)

How can you avoid manually wrapping every async route handler in its own try/catch?

Wrap each handler with a small helper (often called an async handler) that calls it and attaches .catch(next) to the returned promise, so any rejection is automatically forwarded to next(err) without repeating try/catch in every route.

Why must error-handling middleware be registered last, after all other routes and middleware?

Express matches middleware in registration order; an error only reaches error-handling middleware once something calls next(err), and Express then skips forward to the next error-handling middleware defined after that point — one placed earlier would never see errors from routes registered after it.

What's the difference between an operational error and a programmer error?

An operational error is a run-time problem expected to sometimes happen in a working system, like a failed network request or invalid input, and can be handled gracefully; a programmer error is a bug, like calling a function with the wrong type, and generally shouldn't be caught and quietly swallowed, since the program may be in an unknown state.

Why might letting the process crash on a programmer error be safer than catching it and returning a generic 500?

If a bug leaves the process in an undefined state, continuing to serve other requests risks further corruption or unpredictable behavior; crashing (paired with a process manager that restarts it) resets to a known-good state, whereas silently catching and continuing masks the underlying bug entirely.

What are `process.on("uncaughtException")` and `process.on("unhandledRejection")` for, and why don't they replace centralized Express error handling?

They're last-resort, process-wide safety nets for errors escaping all other handling, typically used to log and exit cleanly; they run outside any specific request, so they can't send a proper HTTP response to whoever was mid-request — Express's error middleware, tied to a request/response pair, is what actually replies to the caller.

How would you create a custom error class that carries an HTTP status code?

Extend Error and add a statusCode property, e.g. class ApiError extends Error { constructor(statusCode, message) { super(message); this.statusCode = statusCode; } }, then throw new ApiError(404, "Not found") and read err.statusCode in the error middleware.

Why is distinguishing a custom `ApiError` (with an explicit `statusCode`) from a plain `Error` useful inside centralized middleware?

The middleware can check err.statusCode to tell a recognized, deliberate failure (respond with that specific code and message) apart from an unexpected exception, which gets a generic 500 instead of exposing a message the code never intended to be user-facing.

What HTTP status code should malformed or missing input receive, and why not 500?

400 Bad Request — the request itself is invalid through no fault of the server, so 500 (which conventionally signals the server itself failed) would misrepresent whose fault the problem is and could mislead monitoring that treats 500s as server-health incidents.

Why is returning a consistent JSON error shape across every endpoint valuable?

Callers — frontend code, other services, API consumers — can write one generic piece of error-handling logic that works against every endpoint, instead of special-casing the shape of failures per route.

What's the risk of including the raw `err.message` from an unexpected exception directly in the response body?

An internal exception's message can accidentally include sensitive detail, like a database error mentioning a table name or part of a query — unexpected errors should get a generic client-facing message while the real err.message is only logged internally.

Why does throwing synchronously inside a non-async route handler get caught automatically, while an async one doesn't in Express 4?

Express wraps the synchronous execution of a route handler in its own try/catch internally, so a direct throw is caught and forwarded; a rejected promise from an async function happens after that synchronous call already returned, outside Express's own try/catch, so it's never seen unless the code explicitly forwards it with .catch(next).

What does calling `next()` with no argument mean to Express, versus calling `next(err)`?

next() tells Express to move on to the next matching middleware or route as normal; next(err) tells Express something went wrong, and it should skip ahead past all remaining normal middleware straight to error-handling middleware.

What's the difference between a 401 and a 403 status code?

401 Unauthorized means the caller hasn't proven who they are — missing or invalid credentials; 403 Forbidden means they're known but not permitted to perform this specific action — despite the name, 401 is about authentication, not authorization.

What's a correlation or request ID, and how does it help with error handling in production?

A unique identifier attached to each incoming request and included in every log line and error report for that request, so scattered log entries across multiple services or middleware can be tied back together when debugging one failed request after the fact.

Why might returning different error detail in development versus production be a good practice?

In development, seeing the full stack trace and message speeds up debugging; in production, the same detail handed to a real caller (or a potential attacker) is a liability — an environment check, like NODE_ENV, can gate how much the error handler includes in its response.

If a route's `catch` block calls `next(err)` without a `return`, what happens to any code written after that call in the same handler?

It still runs — calling next(err) doesn't stop control flow on its own, since there's no implicit return; forgetting return next(err) lets the handler keep going and potentially call res.json() a second time for the same request.

What does Express do if a handler calls `res.json()` (or similar) more than once for the same request?

It throws an error, typically "Cannot set headers after they are sent to the client" — HTTP doesn't allow sending a second response for the same request; this commonly happens when a handler forgets to return after responding in one branch and then falls through to respond again.

How would you test that centralized error-handling middleware hides a raw error message from an unexpected exception?

Trigger a route that deliberately throws a plain, generic error (e.g. by forcing a dependency to throw in a test), then assert the HTTP response contains only the safe generic message and status code, never the original message or stack trace, while separately asserting the full error was logged.

Why isn't it always safe to automatically retry a failed operation inside error-handling logic?

Retrying is only safe for idempotent operations that produce the same result no matter how many times they run, like a GET; retrying a non-idempotent one, like a payment charge or an INSERT that isn't otherwise deduplicated, risks performing the action more than once.

What's the difference between a synchronously thrown error and an error passed to a callback's error argument in older Node-style async code?

A synchronous throw propagates up the call stack immediately and must be caught with try/catch at or above where it occurs; an error-first callback's error argument doesn't throw at all — it has to be explicitly checked (if (err) { ... }) when the callback runs, by which point the original try/catch context is long gone.

What's the value of a `notFoundHandler` middleware placed after all defined routes but before the error handler?

It catches any request that didn't match any registered route at all, letting the app return a consistent 404 JSON response for genuinely unknown endpoints instead of Express's default plain-text "Cannot GET /whatever" page.

Why still log the full error object, including its stack trace, inside error middleware even while returning a generic response to the client?

The stack trace and full message are what's actually needed to diagnose and fix the underlying bug later — hiding them from the client is about not leaking internals to an untrusted caller, not about discarding the information altogether; it should still be captured wherever the team actually looks for it, like logs or an error-tracking service.