Whose Fault Is It Anyway?
Before you can respond to an error well, you have to know whose problem it is — and whether anyone can do anything about it. The response follows from the classification.
Every error sits somewhere on two axes: who owns it (the user, your app, or an external dependency) and who can act on it (the user, the system, or no one but time). Get these right and the correct behavior almost writes itself.
Two Questions to Ask at Every Failure Site #
- Whose side is this on? Is it something in the user's environment, a bug or limit in your app, or an external dependency?
- Can anyone act on it right now? Can the user fix it, can the system fix it silently, or must everyone simply wait?
A Working Taxonomy #
- User-side and user-fixable. The user's environment is the blocker, and they can change it. Low phone storage when saving a file — tell them plainly, and point at the fix ("Free up space to save").
- App-side and app-fixable. A bug, a bad assumption, an exhausted pool. The user did nothing wrong and can do nothing useful. Fix it silently if you can; otherwise apologize and log it — don't make the user debug you.
- Dependency-side, wait-only. S3 is down. The user can't do anything but wait, and neither can you beyond retrying. Say so honestly, keep their work safe, and recover automatically when it returns.
- Transient, self-healing. A dropped packet that resends successfully a second later. No one needs to know. Acting on it — or worse, telling the user about it — is the bug.
A Checkout Example #
Imagine a typical Stripe Checkout flow:
- The user clicks Buy.
- Your server creates a Checkout Session.
- The browser redirects to Stripe.
- Stripe collects payment.
- Stripe redirects the browser back to your success page.
- Stripe sends your server a webhook.
- Your server marks the order paid and grants access.
There is no single "checkout failed" state. There are many:
- The user's browser is offline before the request reaches your server.
- Your server receives the request, but the cart is stale or invalid.
- Your server cannot reach Stripe.
- Stripe rejects the request because a price, coupon, tax setting, or account configuration is invalid.
- Your server creates the Checkout Session, but the response to the browser times out.
- The user reaches Stripe, then abandons the checkout.
- The payment method is declined.
- The payment succeeds, but the browser never returns to your success page.
- Stripe sends the webhook, but your endpoint is down.
- Stripe retries the webhook, but your handler is not idempotent and grants access twice.
- Your handler records payment success, but fails before granting access.
- Your handler grants access, but fails before recording that it did so.
- Your database commits the order, but the confirmation email fails.
- Your success page loads before the webhook has arrived and shows the wrong state.
After you list the modes, classify each one. A compact table is often enough:
| Failure mode | Owner | Game over? | Retry? | User affected? | State risk? |
| Browser offline before request | User/network | No | Yes, by user | Yes | Low |
| Cart stale or invalid | Us/user | Yes for this attempt | No | Yes | Low |
| Server cannot reach Stripe | Network/Stripe | No | Yes, with idempotency | Yes | Medium |
| Stripe rejects configuration | Us | Yes until fixed | No | Yes | Low |
| Checkout Session created, browser response times out | Network/us | No | Yes, carefully | Yes | Medium: duplicate sessions |
| User abandons checkout | User | Yes for this attempt | User can restart | Yes | Low |
| Payment method declined | User/bank | Yes for that method | User can retry | Yes | Low |
| Browser never reaches success page | User/network | No | Not needed | Yes | Low if webhook is source of truth |
| Webhook endpoint down | Us | No | Stripe retries | Maybe delayed | Medium |
| Webhook handler is not idempotent | Us | No | Dangerous | Maybe | High: duplicate fulfillment |
| Payment recorded, access grant fails | Us | No | Yes, with repair job | Yes | High: paid but blocked |
| Access granted, recording fails | Us | No | Dangerous | Maybe | High: access without audit trail |
| Confirmation email fails | Us/provider | No | Yes | Mildly | Low |
| Success page races webhook | Us | No | Poll or refresh | Yes | Low to medium |
The exact rows will differ by product, but the questions travel well:
- Whose fault is it? "Fault" is shorthand for ownership: who can prevent it, explain it, or fix it?
- Is it game over? Some failures are terminal until a human changes input, configuration, or business state.
- Can retrying help? If yes, decide who retries: the browser, your server, Stripe, a queue, or an operator.
- Does the user feel it? A user-visible failure needs clear copy and a next action.
- Can state tear or corrupt? Payment systems especially need idempotency keys, webhook deduplication, reconciliation jobs, and a single source of truth.
Real-World Failures Count Too #
Not every failure is a code failure. Sometimes the parcel really did get lost.
Software that touches the real world cannot be fully deterministic. The warehouse may ship the wrong item. The customer may mistype an address. A carrier may say "delivered" while the customer says nothing arrived. Inventory may be wrong. A support agent may issue a replacement while an automated retry is still running.
These are still failure modes. They need owners, states, recovery paths, messages, and measurements:
- Carrier: delayed, lost, damaged, delivered-but-disputed.
- Customer: wrong address, unavailable for delivery, payment method declined.
- Warehouse: wrong item, missed shipment, inventory mismatch.
- Us: bad order state, duplicate replacement, refund recorded in one system but not another.
The job is not to make the world deterministic. The job is to model enough of the messy states that the product behaves honestly when the world does not follow the happy path.
Deterministic vs. IO Errors #
Cutting across whose fault is a second question that decides your whole strategy: will doing the exact same thing again give the exact same result?
- Deterministic errors are pure. Parsing a malformed string, dividing by zero, a failed schema validation, an out-of-range index. The inputs fully determine the outcome, so retrying is pointless — the same input will fail the same way, forever. The only fixes are different input or different code.
- IO / nondeterministic errors depend on the world outside your function: the network, the disk, another process, the clock. A timeout, a dropped connection, a locked file, a 503. Here the same call may well succeed a moment later, so retrying (with backoff) is a legitimate remedy.
We should think about these differently, and so treat them differently:
- Never retry a deterministic error. If parsing failed once, it will fail every time — a retry loop just burns CPU and hides a bug. Surface it, fix the input or the parser.
- Consider retrying an IO error — but only if the operation is idempotent and the failure looks transient. See Do or Do Not. Then Retry..
- Push IO to the edges. Keep the deterministic core pure and testable; concentrate the retryable, failure-prone IO in a thin outer layer. This alone removes a huge class of confusion about "can I retry this?"
A quick test: if I called this again with the same arguments and the same code, could the result differ? If no, it's deterministic — don't retry. If yes, it's IO — retrying is on the table.
The two calls below look superficially similar — both can "fail" — but they demand opposite treatment:
function parseConfig(text: string): Config {
try {
return ConfigSchema.parse(JSON.parse(text));
} catch (cause) {
throw new Error("Invalid config", { cause });
}
}
async function loadConfig(url: string, attempts = 3): Promise<Config> {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url);
if (res.status >= 500) throw new Error(`Transient ${res.status}`);
if (!res.ok) throw new PermanentError(res.status);
return parseConfig(await res.text());
} catch (err) {
if (err instanceof PermanentError || i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 2 ** i * 100));
}
}
throw new Error("unreachable");
}
Notice the boundary: loadConfig retries the fetch, but the parseConfig inside it is still deterministic — a malformed body is surfaced immediately, never retried. That's "push IO to the edges" in miniature: the retry loop wraps only the part that could actually change.
No One Likes Toast Flying Into Their Face #
The most common mistake is announcing failures that already fixed themselves. A packet dropped and resent, a token refreshed, a request retried and succeeded — these are non-events. Interrupting the user with a toast for each one shatters their focus for no benefit.
A rule of thumb: only interrupt the user when the user must act. If the system already recovered, stay silent. If the user can fix it, tell them clearly and point at the fix. If everyone must wait, show calm, non-modal status — not a barrage of pop-ups.
When the user should know — internet offline, for instance — say exactly what's happening and what they can try, in as few words as possible. "You're offline. We'll keep your changes and sync when you reconnect." That's precise and calm. A stack trace, an error code with no context, or three stacked toasts are none of those things.
Accessibility Failures Are Errors #
We tend to think of errors as things that throw. But if a user cannot perceive, operate, or understand the interface, the software has failed them just as surely as a crashed request — it's just a failure the exception handler never sees.
Illegible text is a good example. If the contrast is too low, the font too small, or the color the only signal, the content did not reach the user. And, crucially, this is a fault of the creator, not the user. The user did nothing wrong; the design produced an unreadable result.
That places accessibility failures firmly on the app/creator side of the taxonomy — with an important consequence for who can act:
- The creator owns the fix. Sufficient contrast, scalable text, and signals that don't rely on color alone are the author's responsibility, not an optional polish.
- The user's only recourse is recovery or assistance. Faced with illegible text, all a user can do is try to recover legibility — zoom, increase system font size, enable high-contrast or reader mode — or seek assistance from a screen reader, magnifier, or another person. That we routinely force users into these workarounds is the measure of the failure.
- Respect the recovery paths. Because those workarounds are often the only remedy, breaking them is a serious error: don't disable zoom, don't fix font sizes in pixels that ignore user preferences, don't hide focus outlines, don't defeat the reader/high-contrast modes the user is relying on to recover.
Treating accessibility issues as first-class errors changes how you handle them: they belong in your failure inventory, they deserve real error messages and states, and they should be caught in testing — not left for a user to discover and route around.
Front End vs. Back End Notes #
- Front end: map each error to one of the four classes and pick the interaction (silent, inline hint, or blocking) from the class — not from where the
catch happened to land. Treat legibility and operability failures as real errors, and never break the user's recovery paths (zoom, font scaling, contrast, reader mode).
- Back end: encode the classification in the response — the right status code plus a machine-readable reason — so clients can choose the behavior without guessing. A 4xx says "your side," a 5xx says "our side," a 503 says "wait." Distinguish deterministic rejections (400 — don't retry) from transient IO failures (503 — safe to retry).
Related chapters: Failure Modes · Writing Insanely Great Error Messages · Keep Informed and Carry On