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 #

  1. Whose side is this on? Is it something in the user's environment, a bug or limit in your app, or an external dependency?
  2. 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 #

A Checkout Example #

Imagine a typical Stripe Checkout flow:

  1. The user clicks Buy.
  2. Your server creates a Checkout Session.
  3. The browser redirects to Stripe.
  4. Stripe collects payment.
  5. Stripe redirects the browser back to your success page.
  6. Stripe sends your server a webhook.
  7. Your server marks the order paid and grants access.

There is no single "checkout failed" state. There are many:

After you list the modes, classify each one. A compact table is often enough:

Failure modeOwnerGame over?Retry?User affected?State risk?
Browser offline before requestUser/networkNoYes, by userYesLow
Cart stale or invalidUs/userYes for this attemptNoYesLow
Server cannot reach StripeNetwork/StripeNoYes, with idempotencyYesMedium
Stripe rejects configurationUsYes until fixedNoYesLow
Checkout Session created, browser response times outNetwork/usNoYes, carefullyYesMedium: duplicate sessions
User abandons checkoutUserYes for this attemptUser can restartYesLow
Payment method declinedUser/bankYes for that methodUser can retryYesLow
Browser never reaches success pageUser/networkNoNot neededYesLow if webhook is source of truth
Webhook endpoint downUsNoStripe retriesMaybe delayedMedium
Webhook handler is not idempotentUsNoDangerousMaybeHigh: duplicate fulfillment
Payment recorded, access grant failsUsNoYes, with repair jobYesHigh: paid but blocked
Access granted, recording failsUsNoDangerousMaybeHigh: access without audit trail
Confirmation email failsUs/providerNoYesMildlyLow
Success page races webhookUsNoPoll or refreshYesLow to medium

The exact rows will differ by product, but the questions travel well:

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:

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?

We should think about these differently, and so treat them differently:

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:

// DETERMINISTIC: same input -> same result, always.
// A failure here is a bug in the input or the code. Retrying is futile.
function parseConfig(text: string): Config {
  try {
    return ConfigSchema.parse(JSON.parse(text));
  } catch (cause) {
    // Don't loop. Surface it so a human fixes the input or the parser.
    throw new Error("Invalid config", { cause });
  }
}

// IO: the world can change between attempts.
// A timeout or 5xx may succeed a moment later, so retry with backoff --
// but only transient failures, and only because GET is idempotent.
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); // 4xx: don't retry
      // Note: parseConfig stays deterministic -- a parse failure here is
      // NOT retried, even though it happens inside the IO path.
      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)); // backoff
    }
  }
  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.

Inform Precisely, Not Verbosely #

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:

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 #


Related chapters: Failure Modes · Writing Insanely Great Error Messages · Keep Informed and Carry On