Error Handling
Categorizing API errors, retry strategies, circuit breaking, user-facing messaging
Error Handling
The default try/catch on a fetch lumps networks issues, server bugs, validation failures, and cancellations into the same Error. That's not enough information to do the right thing for the user.
Error Taxonomy
Error
├── NetworkError DNS, TCP, TLS, timeout
├── HttpError non-2xx response
│ ├── ClientError 4xx — don't retry, map to UX
│ │ ├── UnauthorizedError 401
│ │ ├── ForbiddenError 403
│ │ ├── NotFoundError 404
│ │ ├── ConflictError 409
│ │ ├── ValidationError 422 — has field-level info
│ │ └── RateLimitedError 429 — has Retry-After
│ └── ServerError 5xx — retry with backoff
├── ParseError body didn't match schema
├── DomainError backend-defined business errors
└── AbortError cancellation, not really an errorA Real Implementation
export class AppError extends Error {
constructor(message: string, public cause?: unknown) {
super(message);
this.name = new.target.name;
}
}
export class NetworkError extends AppError {}
export class TimeoutError extends NetworkError {}
export class HttpError extends AppError {
constructor(public status: number, public body: unknown, message?: string) {
super(message ?? `HTTP ${status}`);
}
}
export class UnauthorizedError extends HttpError {}
export class ForbiddenError extends HttpError {}
export class ValidationError extends HttpError {
constructor(public fieldErrors: Record<string, string>, body: unknown) {
super(422, body, 'Validation failed');
}
}
export class RateLimitedError extends HttpError {
constructor(public retryAfterSeconds: number | null, body: unknown) {
super(429, body, 'Rate limited');
}
}
export class DomainError extends AppError {
constructor(public code: string, message: string) { super(message); }
}async function buildError(res: Response): Promise<AppError> {
let body: unknown;
try { body = await res.json(); } catch { body = undefined; }
switch (res.status) {
case 401: return new UnauthorizedError(401, body);
case 403: return new ForbiddenError(403, body);
case 422: return new ValidationError(parseFieldErrors(body), body);
case 429: return new RateLimitedError(
Number(res.headers.get('Retry-After')) || null, body);
default:
return new HttpError(res.status, body);
}
}Retry Strategy
| Error | Retry? | Backoff |
|---|---|---|
NetworkError / TimeoutError | Yes (3×) | Exponential + jitter |
ServerError 5xx | Yes (3×) | Exponential + jitter |
RateLimitedError | Yes | Honor Retry-After |
ClientError 4xx (except 408/429) | No | — |
AbortError | No | — |
ParseError | No | — |
DomainError | No | — |
Exponential Backoff With Jitter
function backoffDelay(attempt: number) {
// 1s, 2s, 4s, 8s ... with ±50% jitter
const base = Math.min(1000 * 2 ** attempt, 30_000);
const jitter = base * 0.5 * Math.random();
return base + jitter;
}TanStack Query Retry Policy
new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof HttpError && error.status < 500 && error.status !== 408 && error.status !== 429) {
return false;
}
return failureCount < 3;
},
retryDelay: (attempt, error) => {
if (error instanceof RateLimitedError && error.retryAfterSeconds) {
return error.retryAfterSeconds * 1000;
}
return Math.min(1000 * 2 ** attempt, 30_000);
},
},
mutations: {
retry: 0, // never retry mutations by default
},
},
});Mutations default to no retry. A failed mutation might already have side-effects on the server. Only retry with an idempotency key.
Timeouts
Two layers:
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(new TimeoutError('Request timeout')), 15_000);
try {
const res = await fetch(url, { signal: ac.signal });
// ...
} finally {
clearTimeout(timeout);
}Why 15s on mobile? Cellular networks can stall briefly. 15s tolerates that without making the user wait too long. The number is configurable per endpoint — uploads can be longer, autocompletes much shorter.
Circuit Breaker (For Mobile)
On flaky networks, hammering a failing endpoint wastes battery and quota. After N consecutive failures, fail fast for M seconds.
class CircuitBreaker {
private failures = 0;
private openedAt: number | null = null;
constructor(private threshold = 5, private cooldownMs = 30_000) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.openedAt && Date.now() - this.openedAt < this.cooldownMs) {
throw new NetworkError('Circuit open');
}
try {
const result = await fn();
this.failures = 0;
this.openedAt = null;
return result;
} catch (e) {
if (e instanceof NetworkError || (e instanceof HttpError && e.status >= 500)) {
this.failures++;
if (this.failures >= this.threshold) this.openedAt = Date.now();
}
throw e;
}
}
}Useful per-host. Less useful for a server with planned downtime — there, prefer a maintenance flag from the backend.
User-Facing Messaging
| Error | What to show |
|---|---|
NetworkError | "You're offline — we'll try again when you're back." + Retry button |
TimeoutError | "Taking longer than expected." + Retry |
UnauthorizedError | Silent — refresh token; only show if refresh fails ("Please sign in again") |
ForbiddenError | "You don't have access to this." |
NotFoundError | Empty state with context-specific text |
ValidationError | Field-level errors next to the relevant input |
RateLimitedError | "Too many requests — try again in N seconds." |
ServerError 5xx | "Something went wrong on our end. We're looking into it." + retry |
DomainError | Map by code to localized message |
Don't Leak Internals
// Bad
<Text>{error.message}</Text> // "FetchError: getaddrinfo ENOTFOUND api.example.com"
// Good
<Text>{userFacingMessageFor(error)}</Text>Send the raw error to your error tracker (Sentry), show the friendly version to the user.
Distinguishing User Cancellation From Failures
try { await fn(); } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return; // user navigated away
if (e instanceof TimeoutError) throw e; // we aborted because of timeout
throw e;
}A common bug: showing "request failed" toast when the user just left the screen.
Logging And Alerting
Send structured errors to your tracker:
Sentry.captureException(error, {
tags: {
'error.type': error.name,
'error.status': error instanceof HttpError ? String(error.status) : 'n/a',
'route': currentRoute,
},
extra: {
requestId: response?.headers.get('x-request-id'),
endpoint,
},
});Include the request ID the backend returned — operators can correlate to server logs.
Backend Should Also Help
Frontend can only do so much. Ask the backend for:
- Stable, machine-readable error codes (
code: "INSUFFICIENT_FUNDS"), not freeform messages. Retry-Afteron 429 and 503.- Field-level errors on 422 (
{ field: "email", code: "invalid_format" }). - A
requestIdechoed in headers and body.
A backend that returns { message: "oops" } makes good frontend UX impossible.
Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Toast "request failed" after navigating away | Treating AbortError as failure | Filter AbortError before showing |
| Infinite refresh loop | 401 handler that calls refresh, refresh also returns 401, handler triggers again | Single-flight mutex on refresh; sign out on second 401 |
| App stuck on splash because one request hung | No timeout | Always set a timeout |
| Toast spam after backend outage | No backoff cap; retry on every focus | Cap retry attempts; circuit breaker |
| Field errors don't show | Backend returns 400 with mixed-shape body | Normalize at the API layer |
| Inline error message in wrong language | Showed backend error.message directly | Map error codes to localized strings |