An error is a planned state, not an exception you bolt on later. Every fetch, every mutation, every external call has a failure path, and that path is reachable in production whether or not you designed it. If you treat errors as edge cases, they show up late as bugs and bad screens. If you treat them as states — first-class, designed, copy-written — they show up early as part of the build (this is the design-engineer mindset from design-process.md — "Read every design as an incomplete spec").
The goal is never to hide failure. It's to keep the user oriented and give them a way forward when something breaks.
The Anatomy of a Good Error
A good error message answers three questions, in order. They mirror the three questions every empty state answers (see ui/polish.md — Don't Overlook Empty States):
- What happened? State it plainly. "We couldn't save your changes."
- Why? Give the cause if it helps the user act. "Your session expired." Skip it if it doesn't ("an internal error occurred" tells the user nothing actionable).
- What can I do next? The most important part. Retry, sign in again, contact support, go back. An error with no next step is a dead end.
// ❌ Bad: no cause the user can act on, no next step, leaks internals
<div>Error: ECONNREFUSED 500 at db.query (line 88)</div>
// ✅ Good: plain language + a way forward
<div role="alert">
<h2>We couldn't load your projects</h2>
<p>The connection timed out. This is usually temporary.</p>
<button onClick={retry}>Try again</button>
</div>
Never show a raw stack trace, error code dump, or internal identifier to the user. It's noise to them and a security leak to you (the CLAUDE.md rule: never expose internal error details in API responses). If support needs a reference, surface a short, opaque error id — not the underlying exception (see "Page-Level Errors" below).
Match Severity to Placement
Where an error appears should match how much it blocks the user. Putting a field-level typo in a full-page takeover is as wrong as burying a total data-load failure in a corner toast. Escalate placement with severity:
Does the error block the whole screen from being useful?
├── Yes → Full-page error (404, 500, 403, total load failure)
└── No
Does it affect a whole section or the page's data freshness?
├── Yes → Inline section error or a page-level banner
└── No
Is it tied to one specific control or field?
├── Yes → Field-level / inline-at-the-source error
└── No (transient, non-blocking acknowledgement)
→ Toast (see notifications.md)
- Field-level — validation and per-control failures, shown next to the field (see
forms.md). - Inline section — one panel or list failed while the rest of the page is fine. Scope the error to that region; don't blank the whole screen.
- Banner — page-wide but non-blocking: "We're having trouble syncing. Some data may be out of date." (see
notifications.md— Banners). - Toast — a transient failure the user should notice but that doesn't block them: "Couldn't copy link." Pair it with the action that failed (see
notifications.md— Toasts). - Full page — the screen can't function: the route doesn't exist, the server failed, the user lacks access, or the primary data wouldn't load.
Field & Form Errors
Form validation is the highest-frequency error surface, and it has its own rules. Don't restate them here — design field errors per forms.md:
- Show errors next to their field, not only in a summary at the top.
- On submit, focus the first invalid field.
- Wire
aria-invalidandaria-describedbyso the error is announced with the field. - Don't block typing to prevent invalid input — let it through and validate, so the user gets an explanation instead of a dead keystroke.
The one cross-cutting principle: validate on the user's terms (on blur or on submit), not on every keystroke from the first character — telling someone their email is invalid before they've finished typing it is noise, not help.
Network & Async Errors
Network calls fail in ways local code doesn't: offline, timeout, partial response, or a failure that silently falls back to an empty array. Each needs distinct handling.
- Offline — detect it and say so explicitly. A persistent banner ("You're offline. Changes will sync when you reconnect.") beats a pile of individual request failures. Listen for
online/offlineevents and surface the state once. - Timeout — a request that hangs is worse than one that fails fast, because the user doesn't know whether to wait. Set a sensible timeout, then show a real error with a retry instead of an infinite spinner (an infinite spinner is a lie — see
interactions.md— Honest Loading States). - Empty vs failed — the most dangerous async error is the one that looks like emptiness. When a request fails and the code falls back to
[], the UI renders a cheerful "Nothing here yet" — and the user trusts it. Don't. Distinguish "loaded, genuinely empty" from "failed to load" and render the error-adjacent empty state, not the first-use one (seeui/polish.md— empty state type 5, error-adjacent).
// ❌ Bad: failure collapses into a fake "empty" state
const { data = [] } = useQuery(...);
return data.length === 0 ? <FirstUseEmpty /> : <List items={data} />;
// ✅ Good: failure is its own state, distinct from empty
const { data, isError, refetch } = useQuery(...);
if (isError) return <LoadFailed onRetry={refetch} />;
if (data?.length === 0) return <FirstUseEmpty />;
return <List items={data} />;
Retry Patterns
Most transient failures resolve themselves. The question is who retries and how visibly.
- Manual retry — the default for user-visible failures. A clear "Try again" button next to the error message. The user controls the timing and sees that something is happening.
- Automatic retry with backoff — for transient infrastructure blips, retry silently a small number of times with exponential backoff before surfacing an error. Don't retry forever, and don't retry non-idempotent actions blindly (a duplicate POST can double-charge). Use an idempotency key so a retried mutation is safe (see
forms.md— Submission Rule). - Optimistic rollback — when you updated the UI optimistically and the server rejected it, roll back to the previous state and tell the user, ideally with an Undo or a retry (see
interactions.md— Optimistic Updates). The cause of the failure should stay visible — don't bounce the user elsewhere to show the error.
// Exponential backoff for transient, idempotent reads
async function fetchWithRetry(url, { retries = 3 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url);
if (res.ok) return res;
if (res.status < 500) throw new Error(`Request failed: ${res.status}`); // don't retry 4xx
} catch (err) {
if (attempt === retries) throw err;
const delay = Math.min(1000 * 2 ** attempt, 8000); // 1s, 2s, 4s, capped
await new Promise((r) => setTimeout(r, delay));
}
}
}
Retrying a 4xx (bad request, unauthorized, forbidden) is pointless — the input or permissions won't change by asking again. Only retry 5xx and network-level failures.
Page-Level Errors
When the whole screen can't function, the full-page error is the screen. It should never be a dead end — every page-level error carries a way out.
404 / Not Found:
- Be navigational, not apologetic. The user took a wrong turn or followed a stale link; help them get back on the road.
- Offer the most useful destinations: home, search, the parent section. A 404 that only says "Page not found" with no link out is a trap.
- Keep the global navigation visible so the user isn't stranded in a chrome-less void.
500 / Something Went Wrong:
- Be honest and calm: "Something went wrong on our end." Don't blame the user, and don't pretend it's fine.
- Provide an escape hatch (retry, go home) and, for support, a short opaque error id the user can quote. The id maps to the real exception in your logs (PostHog
captureException, Sentry) — the user never sees the exception itself. - Never render the stack trace or internal error message. That's both a poor experience and an information leak.
<main role="alert">
<h1>Something went wrong on our end</h1>
<p>We've logged the problem and are looking into it.</p>
<div>
<button onClick={retry}>Try again</button>
<a href="/">Go home</a>
</div>
{/* Opaque reference for support — not the underlying exception */}
<p className="text-muted-foreground">Reference: {errorId}</p>
</main>
403 / Permission:
- Say it plainly: "You don't have access to this page." Soft, neutral language that hides the real cause just makes the user retry uselessly.
- Give the right next step: request access, switch account, or go back. Retrying is useless here — the difference from a 500 is that re-attempting won't help (this mirrors the permission-based empty state in
ui/polish.md).
Partial & Degraded States
Real screens are composites. One widget's data can fail while everything around it loads fine. Don't let one failed call take down a whole page, and don't pretend the failed part succeeded.
- Scope failures to the smallest region that owns the data. A failed "Recent activity" panel shows its own inline error with a retry; the rest of the dashboard stays usable.
- Degrade gracefully: if a non-critical enhancement fails (a recommendation strip, a live metric), hide it or show a quiet placeholder rather than blocking the core task.
- Make the degraded state legible. "Live data unavailable: showing last known values" is honest; silently showing stale numbers as if fresh is not.
Error boundaries are the mechanism: wrap independent regions so a thrown error in one renders that region's fallback instead of unmounting the page.
Error Copy
The words carry most of the recovery. Write them like a calm human, not a server.
- No blame. "That email is already in use" not "You entered a duplicate email." Describe the situation, not the user's mistake.
- No jargon, no codes. "We couldn't reach the server" not "NetworkError: fetch failed." The user can't act on
ECONNREFUSED. - Be specific and actionable. "Add at least one item before checking out" beats "Invalid cart." Name the fix.
- No filler openers or exclamation marks. Drop "Oops," "Uh oh," and the trailing "!" from error text: they add false cheer and noise to a moment the user is already frustrated. Lead straight with what happened.
- Phrase hints positively, and before the mistake. "Use only letters" guides better than "Don't use numbers," and stating the constraint up front beats correcting the user after they trip on it. Say what to do, not what not to do.
- Match tone to severity. A failed autosave is mild; a failed payment deserves more presence and a clearer path. Don't make a minor hiccup sound catastrophic, or a serious failure sound trivial.
- Don't over-apologize. One honest "Something went wrong" is better than three sorries. Spend the words on the next step.
- No em dashes in the shipped copy. This is user-facing UI text, so it follows the project copywriting rule: use commas, colons, or rephrase (the CLAUDE.md em-dash rule applies to the strings in the product, not to this reference doc).
Prevent Errors Before They Happen
The best error is the one the user never hits. A lot of error handling is really constraint design upstream.
- Constrain the input so invalid states are hard to reach: sensible defaults, pickers instead of free text where the format is rigid, disabled-until-valid only where the requirement is truly unambiguous (but don't pre-disable submit just to enforce completeness — let users submit and see what's missing, per
forms.md). - Confirm destructive actions or offer Undo with a safe window, so a misclick isn't a catastrophe. Make the safe option more prominent than the destructive one (see
interactions.md— Confirm Destructive Actions). - Validate early and locally where you can, so the round-trip to the server isn't the first time the user learns something's wrong.
- Make the happy path obvious so users fall into success by default. Most "errors" are really the UI failing to guide.
Accessibility
An error a sighted user sees instantly can be invisible to a screen-reader user unless you announce it.
- Wrap critical, must-notice errors in
role="alert"(which impliesaria-live="assertive") so they're announced immediately, interrupting the current speech (seeinteractions.md— Accessibility Announcements). - Use
aria-live="polite"for non-urgent error updates that shouldn't interrupt (a retry-in-progress status). - On a form submit with errors, move focus to the first invalid field or the error summary, so a keyboard user lands on the problem instead of hunting for it.
- Don't signal an error with color alone. Pair red with an icon and text (see
review-rules.md— Color-only information, andui/color.md— Don't Rely on Color Alone). - Ensure error text meets contrast minimums — error red on a tinted error background often fails 4.5:1 if you're not careful (see
ui/color.md).
Checklist
Anatomy
- Every error answers: what happened, why (if useful), what next
- A clear next step on every error (retry, sign in, go back, contact support)
- No stack traces, error codes, or internal messages shown to the user
- Support reference is a short opaque id, not the raw exception
Placement
- Severity matches placement (field < inline < toast < banner < full page)
- Section failures scoped to their region, not the whole screen
- Field errors follow
forms.md(next to field, focus first error)
Async & Recovery
- Offline state surfaced once, not as many request failures
- Timeouts resolve to a real error + retry, never an infinite spinner
- Failed loads render an error state, never a fake "empty" state
- Manual retry for visible failures; backoff for transient ones
- Only 5xx / network failures are retried, never 4xx
- Mutations use an idempotency key before retrying
Page-Level
- 404 is navigational with links out; global nav stays visible
- 500 is honest, has an escape hatch and an opaque error id
- 403 says it plainly and offers request-access / switch-account
Resilience
- Error boundaries isolate independent regions
- Degraded states are labeled honestly, never silently stale
Copy & Accessibility
- Copy is blameless, jargon-free, specific, and actionable
- No em dashes in shipped error copy
- Critical errors use
role="alert"; focus moves to the problem - Errors never rely on color alone (icon + text + color)