Error Handling & Recovery
Turn every failure into a guided path forward: find each way the app can fail, give it a specific human message, a correct placement, and at least one recovery action. The prime directive: never blame the user, and never leave them at a dead end — every error state ships with a way out. Deliver an error-state matrix covering the failure modes plus the implemented components and copy — not messaging guidelines alone.
When to use / when not to
Use for: error message copy, validation UX, network/offline failure handling, permission-denied and 404 pages, recovery workflows, graceful degradation, error accessibility.
Hand off instead when the real need is:
- Preventing the wait/failure in the first place (retries as performance, optimistic rollback mechanics) →
skills/frontend-design/performance-optimization
- The loading half of the async lifecycle (skeletons, spinners, empty states) →
skills/frontend-design/loading-states — this skill owns what happens when loading fails
- Error animation timing →
skills/frontend-design/interaction-physics
- Full accessibility audit beyond error surfaces →
skills/frontend-design/accessibility-excellence
Step 0 — Inspect the codebase, then ask only what's left
- Harvest existing error surfaces: grep for
catch, .catch(, onError, error state variables, toast.error, alert(, error boundary components, and string literals like "error", "failed", "wrong", "invalid". Collect the actual message strings — the current copy is your before-picture.
- Map failure classes present in the app: forms (validation), API calls (network/server), auth'd resources (permission), routing (404), plus anything domain-specific (payment declines, quota limits, file-type rejections).
- Find the gaps: catch blocks that swallow errors silently, fetches with no error branch, forms that only style the border red, missing error boundaries around independent sections, no offline handling.
- Check a11y wiring: search for
role="alert", aria-live, aria-invalid, aria-describedby. Absence = automatic fix items.
Ask the user (one batch, only if not inferable): the support channel to point to (email/help center — needed for system-error copy), and whether any operations are high-stakes (payments, destructive actions) needing extra-careful recovery. If unanswerable, use a placeholder support link, flag it, and proceed.
Workflow
1. Build the error-state matrix
For every failure mode found in Step 0, fill one row: failure mode → what the user was doing → message (what happened / why / what to do) → placement → recovery action(s) → a11y wiring. This matrix is the audit deliverable and the implementation checklist.
2. Write each message with the four-part anatomy
- What happened — specific, plain language ("Email already in use", not "Invalid input")
- Why — one line of context, no jargon (never surface "CORS policy violation" to a user)
- What to do — the concrete next step(s)
- Where to get help — support pointer for system errors, with an error/reference ID when the backend provides one
Copy rules: never blame ("you entered an invalid…" → "please enter a…"); state the fix, not just the rule; keep the user's data intact in the message's promises only if the app actually preserves it — never claim "we saved your work" unless it's true.
3. Decide placement and recovery per failure class
| Class |
Placement |
Timing |
Recovery |
| Validation |
Inline, adjacent to the field |
On blur or submit — never on each keystroke of an incomplete entry; clear immediately once fixed |
Fix guidance; shortcut when it exists ("Sign in instead") |
| Network |
Section-level state or toast; full state if the whole view failed |
On failure |
Retry button; preserve entered data; offline continuation if supported |
| Permission |
Full state replacing the blocked content |
On load/action |
Explain why + "Request access" or path to the owner |
| System/5xx |
Section or page state |
On failure |
Retry + support contact + error ID |
| 404 |
Dedicated page |
On route |
Search + links to key destinations |
Fork rules:
- Inline vs modal vs page: field-scoped → inline; a blocking, high-stakes failure mid-flow (payment declined) → modal recovery; the whole resource unavailable → full page/section state. Toasts only for failures unrelated to the user's current focus — never for form validation.
- Auto-retry vs manual: idempotent reads may retry automatically with backoff; mutations get an explicit user-triggered retry only (never silently resubmit a payment or post).
- Degrade, don't break: an unavailable feature is disabled with an explanation, not hidden and not a crash. Wrap independent UI regions in error boundaries so one failure can't blank the page.
4. Implement with accessibility built in
- Dynamic errors announce via
role="alert" (task-blocking) or aria-live="polite" (background).
- Fields:
aria-invalid="true" + aria-describedby pointing at the message element.
- On submit failure, move focus to the first invalid field or an error summary linking to each.
- Icon + text + color on every indicator — never color alone.
- Canonical markup/CSS for every class and the recovery patterns:
references/patterns.md — read it when writing the code.
5. Verify
Force each failure (invalid input, network offline in devtools, a 500 via bad endpoint, an unknown route) and confirm: message renders in the right place, recovery action works, entered data survives, screen reader announcement fires (or the wiring is present), nothing fails silently.
Required output format
Deliver both artifacts:
1. The code — implemented error components, copy strings, error boundaries, and a11y wiring, edited into the app's existing component and styling conventions.
2. Error-State Matrix (markdown):
## Error-state matrix
| # | Failure mode | Class | Message (final copy) | Placement | Recovery | A11y | Status |
| 1 | Signup email taken | validation | "Email already in use…" | inline under field | "Sign in instead" link | aria-invalid + describedby + role=alert | implemented |
| 2 | Feed fetch fails | network | "We couldn't load your feed…" | section state | Retry | polite live region | implemented |
| 3 | Card declined | system | "Your card was declined…" | modal | Try again / other method | alertdialog, focus trapped | implemented |
## Silent failures fixed
| Location | Was | Now |
## Copy changes
| Before | After |
## Assumptions
[Support contact used, failure modes you could not reproduce, etc.]
Quality bar (check before delivering)
Hard don'ts: don't promise recovery the app can't deliver ("your work is saved" without persistence); don't use humor for destructive or payment failures; don't add error UI that hides the actual error from developers — keep console/telemetry logging intact.
Integration
skills/frontend-design/loading-states → hands you its error-empty placements; you expand them into full messages + recovery. The loading→error transition should reuse the same layout slot.
skills/frontend-design/performance-optimization → optimistic-UI rollbacks from that skill need your failure toasts/messages when a background sync fails.
skills/frontend-design/component-architecture → provides the form/input components you add error variants to; add ErrorState as a shared component there.
skills/frontend-design/accessibility-excellence ← audits your live-region and focus behavior; build to its standards up front.
References
references/patterns.md — bad→good message rewrites, complete markup for each failure class (validation, network, permission, system, 404), inline/modal/progressive recovery patterns, graceful-degradation snippets, error styling CSS, and the a11y wiring blocks. Read it when writing the components and copy.