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.

  • Frontend
  • $frontend-error-handling
  • 197 lines
  • 2 files
  • ~2,400 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- frontend-error-handling

Installs to ~/.agents/skills/frontend-error-handling/ for every project on this machine.

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

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.

~/.agents/skills/frontend-error-handling/SKILL.md

--- name: frontend-error-handling description: Makes web frontends fail gracefully - a typed fetch layer that treats non-2xx as errors, timeouts with AbortSignal, retries with backoff only for… license: MIT ---

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

  1. 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.).
  2. 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
  1. List the failure classes the app actually meets and decide a response per class (table below) before writing code.

Error taxonomy and responses

ClassExamplesUser seesRetry?Report?
Network / offlineTypeError: Failed to fetch, navigator.onLine false“You’re offline. We’ll retry when you’re back.”Yes, on reconnectNo (expected)
TimeoutAbortSignal.timeout fired“This is taking longer than usual” + RetryYes, idempotent onlyIf frequent
400 / 422 validationBad inputField-level messagesNoNo
401Session expiredRedirect to sign-in, preserve return URL and draftAfter re-authNo
403Missing permissionExplain and offer request-access pathNoMaybe
404Deleted or wrong idNot-found state with navigationNoOnly if from an internal link
409 / 412Conflict, stale versionShow both versions or reload promptNoNo
429Rate limitedWait messageYes, honour Retry-AfterIf frequent
5xxServer bug or outageGeneric failure with Retry and support reference idYes, bounded, idempotent onlyServer side does; client adds context
Render / runtime bugTypeError in componentBoundary fallback for that regionReset on navigationYes, always
Stale chunk after deployChunkLoadError, failed dynamic importSilent reload onceReloadCount 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’s retries: 0 to 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 from onError too.
  • Next.js App Router: error.tsx (must be a client component, receives error and reset) per route segment, global-error.tsx for the root layout, not-found.tsx for 404s. Server errors arrive with a digest instead of the message in production: show the digest as the support reference.
  • Vue: app.config.errorHandler = (err, instance, info) => report(err, { info }) plus onErrorCaptured in layout components for regional fallbacks. SvelteKit: +error.svelte and handleError hooks.

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 beforeSend hook 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.message from 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem