MiddlewareGENERALFRAMEWORK-SPECIFICLANGUAGE-SPECIFICRUNTIME-SPECIFIC

The Error Boundary

One place that turns any failure below it into a response the client can act on and a log line you can investigate — plus the specific failures it will not catch.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

When something below fails, what turns that into a response, and what decides which response?

The requirement

Whatever breaks — bad input, a missing row, a dead dependency, a bug — the client gets a response it can interpret and the team gets enough to debug it, without leaking internals.

The obvious build

Wrap each handler in try/catch, log the error, and return 500 with the message. It is explicit, it is local, and the message helps whoever is debugging.

Why it breaks

Every failure becomes a 500, so a client cannot tell "you sent something invalid" from "our database is down" — which means it cannot decide whether to retry (Retryability: Telling Clients What To Do Next).

How it breaks in production
  • Every failure becomes a 500, so a client cannot tell "you sent something invalid" from "our database is down" — which means it cannot decide whether to retry (Retryability: Telling Clients What To Do Next).
  • err.message reaches the client and carries a constraint name, a table name, a file path or a fragment of SQL (Not Leaking Your Internals).
  • The 500 rate becomes meaningless as a signal because it includes ordinary business outcomes, so nobody can alert on it and eventually nobody looks at it (Alert Fatigue: The Page Nobody Reads).
  • An error thrown *after* the response has started produces Cannot set headers after they are sent on top of the original error, and the original is lost in the noise.
  • An async handler rejects and the framework does not route the rejection to the error path; the request hangs, the client times out, and on Node an unhandled rejection can terminate the process outright.
  • Per-handler try/catch blocks drift: eleven return { error: "..." } and the twelfth returns { message: "..." }, so clients need per-endpoint parsing (The Error Model: Structure Over Apology).
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • The error boundary is the outermost thing in the pipeline. Everything below it — middleware and handlers alike — can fail, and the boundary is the single place where a failure becomes an HTTP response (The Middleware Pipeline).
  • It answers three questions, in order: is this ours or theirs (5xx or 4xx), is it worth waking someone (log level and whether to report), and what does the client learn (a stable machine-readable code, never the internal detail).
  • It answers them from a taxonomy, not from inspecting messages. A small set of error types — invalid input, not found, forbidden, conflict, dependency unavailable, unexpected — each with a fixed status, log level and client-visible shape (An Error Taxonomy That Maps Cause to Response).
  • The mapping lives in one function, so consistency is a property of the code rather than of everyone remembering. Adding a new error type is a change in one place with a compile error at the mapping if the language allows it.
  • Where the boundary is *registered* differs by framework in a way that looks contradictory until you see why. Express selects error middleware by its four-argument signature and requires it after all routes; ASP.NET Core's UseExceptionHandler is an ordinary middleware and must therefore be registered first to be outermost; Koa wraps await next() in a try/catch in its first middleware. All three are "outermost"; only the registration syntax differs.
  • In Go there is nothing to catch. Expected failures are returned values that a handler maps explicitly, and the outermost wrapper exists to recover() from panics — genuinely unexpected failures — and turn them into a 500.

One boundary, three decisions

GENERALThe type-to-status choices in the middle column are contract decisions owned by API Design (The Error Model: Structure Over Apology); what this domain insists on is that they are decided once and applied by one function, not chosen per handler.

Every error that reaches the boundary needs three answers, and the value of a taxonomy is that it gives all three at once from the error's type rather than from someone reading its message at the call site.

The Client learns column is the security-relevant one, and the Log level column is the operational one. Together they are why a 409 on a business rule should not appear in the same alerting stream as a database connection failure.

Error typeStatusLog levelReport to error tracker?Client learns
InvalidInput400 / 422infoNoWhich fields, and why (Reporting Validation Failures)
Unauthenticated401infoNoThat a credential is needed
Forbidden403 or 404warnNoNothing about the object
NotFound404infoNoA stable code
Conflict409infoNoThe conflicting state (Optimistic Concurrency)
RateLimited429infoNoWhen to retry
DependencyUnavailable503 / 504errorYes, aggregatedRetry with backoff; correlation id (Timeouts)
Unexpected500errorYes, every oneA correlation id and nothing else
ClientDisconnectednone — nothing to senddebugNoNothing; excluded from error rate

The errors your boundary will not catch

A boundary is only as good as its coverage, and coverage has specific, well-known holes. Each row below is a failure that reaches production with an error boundary correctly in place, because the error never enters the boundary's call stack.

The first two rows account for most real incidents. Both have the same signature in production: a request that produces no response and no error log, visible only as a client-side timeout and a gap in your metrics.

TriggerSymptomCauseResponse
Async middleware rejects on Express 4Request hangs; no error log; upstream 504The rejection is not passed to next(err) by the framework versionWrap async handlers, or upgrade to a version that propagates rejections — and test it explicitly
Promise created and never awaitedProcess exits on Node, or the error vanishesThe failure is outside every request call stackProcess-level unhandledRejection handler that logs and exits deliberately (Graceful Shutdown)
Error thrown after streaming beganheaders already sent; a truncated body reaches the clientThe status line is already committedDetect res.headersSent, log, and destroy the connection instead of writing again (Request Bodies and Streaming)
Error thrown while serialising the errorFramework default page, possibly with a stack traceThe boundary is not itself defendedWrap the boundary body in its own try/catch with a static fallback response
Middleware above the boundary throwsFramework default responseThe boundary is not outermostRegister it so it wraps the entire chain (Middleware Ordering Is a Correctness Decision)
Stream error event with no listenerProcess-level crash or silent truncationEvent-emitter errors are not exceptions in the request's stackAttach error listeners to every stream, including the request and response
Client disconnects mid-requestECONNRESET counted as a server errorA normal client behaviour mapped to the error taxonomyClassify separately and exclude from the error budget (Error Budgets: Unreliability You Are Allowed to Spend)

What the client sees, and what you keep

The two audiences want opposite things. The client wants a stable, machine-readable classification and nothing that would help an attacker. You want everything: type, message, stack, the route, the principal, the correlation id, and the state of whatever dependency failed.

The correlation id is what makes the split acceptable. Without it, a generic message is a dead end for a support ticket; with it, the sparse response and the full log are two halves of one record (Correlation Ids That Survive Every Hop).

One failure, two records
The message becomes the response
app.use((err, req, res, _next) => {
  console.error(err)
  res.status(500).json({ error: err.message })
})

// -> 500 {"error":"insert or update on table \"orders\" violates
//           foreign key constraint \"orders_customer_id_fkey\""}
// the client cannot act on it; an attacker just learned your schema
Taxonomy out, detail in the log
app.use((err, req, res, _next) => {
  const e = classify(err)                 // -> { type, status, level, report }
  const cid = req.ctx.correlationId

  log[e.level]({
    err_type: e.type, msg: err.message, stack: err.stack,
    route: req.route?.path, principal: req.ctx.principal?.id,
    correlation_id: cid,
  })
  if (e.report) errorTracker.capture(err, { correlation_id: cid })

  if (res.headersSent) return req.socket.destroy()   // cannot change the status now

  res.status(e.status).json({
    error: { code: e.type, message: e.clientMessage, correlation_id: cid },
  })
})

The client receives a stable code it can branch on and an id it can quote to support; you keep the stack, the route and the principal. Crucially the status now comes from the taxonomy, so 500 means "unexpected" and is worth an alert — which is impossible when every failure maps to 500.

How to build it

Most important first.

  • One boundary, registered so that it wraps every middleware, not only the handlers. Errors thrown in authentication or body parsing need the same treatment as errors thrown in business logic.
  • Define a small error taxonomy with an explicit mapping to status, log level, and whether the response body carries detail (An Error Taxonomy That Maps Cause to Response).
  • Never send an exception message to a client. Send a stable code, a human-readable sentence you wrote deliberately, and the correlation id (Correlation Ids That Survive Every Hop).
  • Log the full detail — type, message, stack, correlation id, route template, principal — exactly once, at the boundary. Logging at every level of the stack multiplies volume without adding information (What a Backend Should Actually Log).
  • Distinguish expected failures from unexpected ones in the type system where you can: expected outcomes as return values, unexpected ones as throws. Then a 500 genuinely means "we did not anticipate this" and is worth alerting on (What a Handler Is Responsible For).
  • Handle the "already responding" case explicitly: if headers are sent, you cannot change the status, so log and destroy the connection rather than attempting a second write.
  • Add a process-level backstop — unhandledRejection and uncaughtException on Node, an equivalent elsewhere — that logs and exits deliberately rather than dying silently (Graceful Shutdown).

What can go wrong

Failure modes
  • Errors raised in middleware registered above the boundary, which by construction it cannot see.
  • Errors raised inside the error handler itself — usually while serialising the error — producing a framework default response.
  • Errors after the first byte of a streamed response: the status is already sent, so the only honest signal is an abrupt termination (Request Bodies and Streaming).
  • Errors in a callback or event handler that is no longer inside the request's call stack — a setTimeout, a stream error event, a promise nobody awaited.
  • A catch-all that maps everything to 500, including a NotFound that should have been 404, because the taxonomy was never applied.
  • A boundary that swallows and returns 200 with an error field in the body, so every monitoring signal reports success (Error Boundaries: Three Translations, Not One).
  • Client disconnects surfacing as errors — ECONNRESET, EPIPE, aborted requests — which are not your failure and should not be in your error budget.
What can race
  • A handler writes a response while an asynchronous operation it started fails later; the error arrives with the request already complete, so there is nothing to respond with and only a log entry is possible.
  • A client disconnect races with a response write, producing an error that is neither your bug nor a client-visible failure.
  • On a timeout, the boundary may respond 504 while the underlying work is still running and may still commit (Where the Transaction Boundary Goes).
Security
  • Stack traces, SQL fragments, file paths, dependency versions and internal hostnames in an error body are reconnaissance handed to an attacker (Not Leaking Your Internals).
  • Error messages that differ by cause can be an oracle: "user not found" versus "wrong password" confirms which accounts exist (How Passwords Are Actually Attacked).
  • A framework's development error page must be impossible to enable in production, not merely disabled by an environment variable someone could set (Validate at Startup, Fail Loudly).
  • Errors must not log the credentials, tokens or payloads that caused them; the request body in an error log is a common way secrets reach log storage (Secrets in Logs).
  • A boundary that returns 200 on failure hides security-relevant events from every downstream detector.
  • Distinguishing 403 from 404 tells a caller which objects exist. Decide that deliberately as a policy rather than letting the mapping decide it (Object-Level Authorization).
Misreads
  • "Catch everything and return 500." A 500 tells the client to retry something that will never succeed, and pollutes the one signal that should mean "unexpected".
  • "The error handler is last, so it runs last." In Express it is registered last and is the first thing reached once an error propagates. Registration position is not onion position (Middleware Ordering Is a Correctness Decision).
  • "Async errors are handled." Framework- and version-specific. Write a test that throws inside an async middleware, in the version you deploy, and see what happens.
  • "Returning 200 with { ok: false } is friendlier." It makes every monitor, load balancer, retry policy and dashboard believe the request succeeded (Error Boundaries: Three Translations, Not One).
  • "Retryable and safe to retry are the same." Retryable is whether a retry could succeed; safe to retry is idempotency, and it is your responsibility, not the client's (Idempotency in Backends).
  • "More logging is safer." Logging the same failure at four levels quadruples volume, and the copies disagree about context.

Operating it

How you see it in production
  • Errors counted by taxonomy type and route template, not just by status. "409 conflict on /orders/:id" is actionable; "4xx rate" is not.
  • A correlation_id present in both the error log and the response body, so a user-reported failure is one query away from its stack trace (Correlation Ids That Survive Every Hop).
  • A separate counter for unexpected — the type that produced a 500. If the taxonomy is applied well, this number is small and every increment deserves attention.
  • A counter of errors the boundary could not handle: thrown after headers, or caught by the process-level backstop. Nonzero means the boundary has a hole.
  • Client-disconnect errors tracked separately so they do not pollute the error rate or the error budget (Error Budgets: Unreliability You Are Allowed to Spend).
What changes at 10x and 100x
  • Error volume scales with traffic, and error logging is expensive: stack traces are large and errors cluster. One dependency outage can produce more log bytes in ten minutes than a normal day (The Log Bill and What It Is Buying).
  • Sample repetitive errors above a threshold, keeping the first N per type per interval plus an exact count. Losing the count is worse than losing the duplicates.
  • At high traffic the difference between a 400 and a 500 becomes a capacity question: 5xx responses often trigger client retries, and retries during a partial failure are how a degradation becomes an outage (Retry Storms).
  • Error-tracking services charge per event and rate-limit ingestion; a boundary with no sampling will silently drop the errors you most need during an incident.
What this costs
  • A generic client-facing message is safe and unhelpful. The correlation id is what makes it acceptable, and it only works if support can actually search by it.
  • A rich taxonomy is more upfront design than catch (e) { 500 } and pays back the first time you need to answer "should clients retry this".
  • Typed failure returns make expected outcomes explicit and add ceremony to every call site.
  • Centralising error handling means handlers no longer show what happens when they fail — the same invisibility cost every cross-cutting concern has (What Belongs in the Pipeline).

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALOne boundary, a taxonomy, log once, never leak internals — true of any server-side stack.
  • FRAMEWORK-SPECIFICExpress: four-argument error middleware registered after all routes; async rejections propagate automatically only in Express 5. Koa and ASP.NET Core: an ordinary try/catch around await next() in the outermost middleware, so it is registered first. Fastify: setErrorHandler, scoped per plugin encapsulation context. Spring: @ControllerAdvice with @ExceptionHandler methods outside the pipeline entirely.
  • LANGUAGE-SPECIFICGo has no exceptions: expected failures are returned and mapped explicitly, and the outermost wrapper exists only to recover() from panics. The taxonomy idea transfers exactly; the catching mechanism does not exist. Rust's Result is the same shape with compiler enforcement.
  • RUNTIME-SPECIFICOn Node an unhandled promise rejection terminates the process by default on current versions, so a missing await is an availability bug rather than a logging gap; in a thread-per-request runtime the equivalent typically kills one request thread and leaves the process running. Same mistake, very different blast radius (Backend Runtime Models).

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.