Frontend Error Handling skill for Codex
No white screens, no silent failures, no retry storms. Codex maps each failure class (offline, timeout, 4xx, 5xx, render bug, stale chunk after deploy) to a user-facing state, a retry rule and a reporting rule before touching code. It wraps fetch so non-2xx responses throw, retries only idempotent requests with jittered backoff and Retry-After, places error boundaries per region, and reports unexpected errors once with release and request ids but no personal data.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- frontend-error-handling Installs to ~/.agents/skills/frontend-error-handling/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install frontend-error-handling The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project frontend-error-handling Installs to .agents/skills/frontend-error-handling/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/frontend-error-handling.zipunzip -o frontend-error-handling.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) frontend-error-handling PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $frontend-error-handling, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Makes web frontends fail gracefully - a typed fetch layer that treats non-2xx as errors, timeouts with AbortSignal, retries with backoff only for safe requests, React error boundaries (react-error-boundary, Next.js error.tsx), Vue errorHandler, global unhandledrejection capture, stale-chunk reload after deploys, and user-facing error states with recovery actions, reported to monitoring with context and without PII. Use when the app shows white screens, swallows failed requests, retries storms, or when adding error reporting to a React, Next.js, Vue or Svelte app.
Use it when
- The app shows blank screens or swallows failed requests
- Adding retries, timeouts or error boundaries to a frontend
- Errors appear after deploys because old chunks no longer exist
- Deciding which frontend errors to report to monitoring and with what context
Use something else when
- You need to install and configure the Sentry SDK itself: use sentry-integration
- The work is error types and propagation in backend code: use error-handling-patterns
- You only need wording and visuals for error, empty and loading states: use ui-copy-states
How to check the result
Mock offline, timeout, 401, 429 and 500 responses and confirm the right UI state and retry count for each, then throw inside one widget and see only that region fall back.
The mistake it prevents
Retrying POST requests after a timeout without an idempotency key, which can create duplicate orders or payments.
Done well: no failure produces a blank screen, every error a user sees says what happened and offers a next step, transient failures recover on their own without hammering the server, and every unexpected error reaches monitoring once, with enough context to reproduce and no personal data.
Inventory first
- Read
AGENTS.md; find the HTTP client (fetch wrapper, axios, ky, TanStack Query, RTK Query), the framework, and any monitoring SDK (@sentry/*, Datadog RUM, etc.). - Search for current handling and its gaps:
rg -n "catch\s*\(\s*\w*\s*\)\s*\{\s*\}" src # empty catches
rg -n "\.catch\(\(\)\s*=>\s*(null|undefined|\{\s*\})\)" src
rg -n "fetch\(" src | rg -v "res.ok|response.ok" # fetch without status checks (rough)
rg -n "ErrorBoundary|error\.tsx|errorHandler|onErrorCaptured" src app
- List the failure classes the app actually meets and decide a response per class (table below) before writing code.
Error taxonomy and responses
| Class | Examples | User sees | Retry? | Report? |
|---|---|---|---|---|
| Network / offline | TypeError: Failed to fetch, navigator.onLine false | “You’re offline. We’ll retry when you’re back.” | Yes, on reconnect | No (expected) |
| Timeout | AbortSignal.timeout fired | “This is taking longer than usual” + Retry | Yes, idempotent only | If frequent |
| 400 / 422 validation | Bad input | Field-level messages | No | No |
| 401 | Session expired | Redirect to sign-in, preserve return URL and draft | After re-auth | No |
| 403 | Missing permission | Explain and offer request-access path | No | Maybe |
| 404 | Deleted or wrong id | Not-found state with navigation | No | Only if from an internal link |
| 409 / 412 | Conflict, stale version | Show both versions or reload prompt | No | No |
| 429 | Rate limited | Wait message | Yes, honour Retry-After | If frequent |
| 5xx | Server bug or outage | Generic failure with Retry and support reference id | Yes, bounded, idempotent only | Server side does; client adds context |
| Render / runtime bug | TypeError in component | Boundary fallback for that region | Reset on navigation | Yes, always |
| Stale chunk after deploy | ChunkLoadError, failed dynamic import | Silent reload once | Reload | Count only |
A fetch layer that tells the truth
fetch resolves on 404 and 500. Wrap it once:
export class HttpError extends Error {
constructor(public status: number, public body: unknown, public requestId?: string) {
super(`HTTP ${status}`);
this.name = 'HttpError';
}
}
type Opts = RequestInit & { timeoutMs?: number; retries?: number };
const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
const RETRYABLE = (s: number) => s === 408 || s === 429 || s >= 500;
const sleep = (ms: number, signal?: AbortSignal) =>
new Promise<void>((res, rej) => {
const t = setTimeout(res, ms);
signal?.addEventListener('abort', () => { clearTimeout(t); rej(signal.reason); }, { once: true });
});
export async function http<T>(url: string, { timeoutMs = 10_000, retries = 2, ...init }: Opts = {}): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase();
const canRetry = IDEMPOTENT.has(method) || new Headers(init.headers).has('Idempotency-Key');
for (let attempt = 0; ; attempt++) {
const signal = init.signal
? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)])
: AbortSignal.timeout(timeoutMs);
try {
const res = await fetch(url, { ...init, signal });
if (res.ok) return (res.status === 204 ? undefined : await res.json()) as T;
const body = await res.json().catch(() => null);
const err = new HttpError(res.status, body, res.headers.get('x-request-id') ?? undefined);
if (!canRetry || !RETRYABLE(res.status) || attempt >= retries) throw err;
const retryAfter = Number(res.headers.get('retry-after'));
await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt), init.signal ?? undefined);
} catch (e) {
if (e instanceof HttpError) throw e;
if (init.signal?.aborted) throw e; // caller cancelled: never retry
if (!canRetry || attempt >= retries) throw e; // network error or timeout
await sleep(backoff(attempt), init.signal ?? undefined);
}
}
}
// Full jitter: random between 0 and 500ms * 2^attempt, capped at 8s
const backoff = (attempt: number) => Math.random() * Math.min(8000, 500 * 2 ** attempt);
- Never retry POST without an idempotency key: a timeout does not mean the server did not process it.
- If the app uses TanStack Query or RTK Query, configure retry there instead
(
retry: (count, err) => ...) and keep the wrapper’sretries: 0to avoid multiplying. - Distinguish user cancellation (AbortError from your signal) from timeouts
(
TimeoutError) and do not show an error for cancellations.
Boundaries for render errors
React (with react-error-boundary):
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
function SectionFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert">
<p>This section couldn't load.</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
<ErrorBoundary FallbackComponent={SectionFallback} resetKeys={[location.pathname]}
onError={(error, info) => report(error, { componentStack: info.componentStack })}>
<RevenueChart />
</ErrorBoundary>
- Place boundaries per independent region (widget, panel, route), plus one at the root. One root boundary turns any widget bug into a full-page failure.
- Boundaries do not catch errors in event handlers, timers or async code. Catch those
locally, or push them into the boundary with
useErrorBoundary().showBoundary(err). - React 19
createRoot(el, { onCaughtError, onUncaughtError, onRecoverableError })is the single place to report all React errors; avoid double reporting fromonErrortoo. - Next.js App Router:
error.tsx(must be a client component, receiveserrorandreset) per route segment,global-error.tsxfor the root layout,not-found.tsxfor 404s. Server errors arrive with adigestinstead of the message in production: show the digest as the support reference. - Vue:
app.config.errorHandler = (err, instance, info) => report(err, { info })plusonErrorCapturedin layout components for regional fallbacks. SvelteKit:+error.svelteandhandleErrorhooks.
Global nets
window.addEventListener('error', (e) => report(e.error ?? e.message, { source: 'window.error' }));
window.addEventListener('unhandledrejection', (e) => report(e.reason, { source: 'unhandledrejection' }));
// Vite apps: stale chunks after a deploy
window.addEventListener('vite:preloadError', (event) => {
if (sessionStorage.getItem('reloaded-for-chunk')) return; // avoid reload loops
sessionStorage.setItem('reloaded-for-chunk', '1');
event.preventDefault();
window.location.reload();
});
For webpack, catch ChunkLoadError in the lazy-import wrapper and apply the same
reload-once rule. Keep old asset files on the CDN for a while after deploys so most users
never hit this.
Reporting
- Use the project’s SDK (Sentry, Datadog, etc.); setup belongs to
$sentry-integration. This skill decides what to report and with which context. - Attach: release/version, route, user id (opaque, not email), feature flags, request id from the failing response, and the action the user took.
- Scrub: tokens, emails, form contents, query strings with personal data. Use the SDK’s
beforeSendhook to drop or redact. - Do not report expected errors (validation, 401, offline). Noise hides real bugs and costs quota.
- Upload source maps to the monitoring service at build time; never serve them publicly unless that is a deliberate choice.
User-facing messages
- Say what happened in plain words, what the user can do, and keep their input. “We couldn’t save your changes. Check your connection and try again.” beats “Error 500”.
- Show a reference id (request id or digest) for support on unexpected failures.
- Put errors near the cause: field errors inline, region errors in the region, only app-wide failures as a page.
- Announce with
role="alert"(or a polite live region for background failures) and move focus to the error summary after a failed form submit.
Gotchas
catch (e) { console.error(e) }in production is a swallowed error.await res.json()on an error page (HTML 502 from a proxy) throws a SyntaxError that hides the real status; read the status first.- Retrying in three layers (axios interceptor, query library, service worker) turns one outage into a retry storm.
- An error boundary whose fallback itself throws unmounts the whole tree up to the next boundary.
- Showing raw
error.messagefrom the server can leak internals; map to safe copy.
Verification
- DevTools offline mode, a throttled “Slow 3G” request, and a blocked URL each show the intended state
- Mock 401, 403, 404, 409, 429 (with Retry-After) and 500 responses (MSW or a proxy) and check UI + retry counts in the network tab
- Throw inside one widget: only its region shows the fallback and “Try again” recovers
- Deploy a new build while a tab is open, navigate to a lazy route: one silent reload
- The monitoring service receives one event per unexpected error, with release and route, and no PII
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Frontend Error Handling"
short_description: "No white screens, no silent failures, no retry storms"
default_prompt: "$frontend-error-handling Our app shows a white screen when one dashboard widget crashes, contain it" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.