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.
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.
When something below fails, what turns that into a response, and what decides which response?
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.
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.
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).
- 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.messagereaches 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 senton 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/catchblocks drift: eleven return{ error: "..." }and the twelfth returns{ message: "..." }, so clients need per-endpoint parsing (The Error Model: Structure Over Apology).
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
UseExceptionHandleris an ordinary middleware and must therefore be registered first to be outermost; Koa wrapsawait next()in atry/catchin 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
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 type | Status | Log level | Report to error tracker? | Client learns |
|---|---|---|---|---|
InvalidInput | 400 / 422 | info | No | Which fields, and why (Reporting Validation Failures) |
Unauthenticated | 401 | info | No | That a credential is needed |
Forbidden | 403 or 404 | warn | No | Nothing about the object |
NotFound | 404 | info | No | A stable code |
Conflict | 409 | info | No | The conflicting state (Optimistic Concurrency) |
RateLimited | 429 | info | No | When to retry |
DependencyUnavailable | 503 / 504 | error | Yes, aggregated | Retry with backoff; correlation id (Timeouts) |
Unexpected | 500 | error | Yes, every one | A correlation id and nothing else |
ClientDisconnected | none — nothing to send | debug | No | Nothing; 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Async middleware rejects on Express 4 | Request hangs; no error log; upstream 504 | The rejection is not passed to next(err) by the framework version | Wrap async handlers, or upgrade to a version that propagates rejections — and test it explicitly |
| Promise created and never awaited | Process exits on Node, or the error vanishes | The failure is outside every request call stack | Process-level unhandledRejection handler that logs and exits deliberately (Graceful Shutdown) |
| Error thrown after streaming began | headers already sent; a truncated body reaches the client | The status line is already committed | Detect res.headersSent, log, and destroy the connection instead of writing again (Request Bodies and Streaming) |
| Error thrown while serialising the error | Framework default page, possibly with a stack trace | The boundary is not itself defended | Wrap the boundary body in its own try/catch with a static fallback response |
| Middleware above the boundary throws | Framework default response | The boundary is not outermost | Register it so it wraps the entire chain (Middleware Ordering Is a Correctness Decision) |
Stream error event with no listener | Process-level crash or silent truncation | Event-emitter errors are not exceptions in the request's stack | Attach error listeners to every stream, including the request and response |
| Client disconnects mid-request | ECONNRESET counted as a server error | A normal client behaviour mapped to the error taxonomy | Classify 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).
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 schemaapp.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 —
unhandledRejectionanduncaughtExceptionon Node, an equivalent elsewhere — that logs and exits deliberately rather than dying silently (Graceful Shutdown).
What can go wrong
- 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 streamerrorevent, a promise nobody awaited. - A catch-all that maps everything to 500, including a
NotFoundthat 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.
- 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).
- 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).
- "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
- 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_idpresent 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).
- 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.
- 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/catcharoundawait next()in the outermost middleware, so it is registered first. Fastify:setErrorHandler, scoped per plugin encapsulation context. Spring:@ControllerAdvicewith@ExceptionHandlermethods 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'sResultis the same shape with compiler enforcement. - RUNTIME-SPECIFICOn Node an unhandled promise rejection terminates the process by default on current versions, so a missing
awaitis 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.