How to Handle API Timeout and Retry Scenarios in RealWorld Clients: 7 Resilience Strategies
Use AbortController to enforce a 10-second request ceiling, wrap fetches in limited exponential-backoff retry loops that only target 5xx responses and network errors, and surface clear "Network error – please try again" messages when all retries exhaust.
The gothinkster/realworld repository encodes battle-tested patterns for building resilient front-end applications that remain responsive during backend instability. By examining the specs/e2e/helpers/api.ts test harness and the official error-handling specifications, you can implement robust timeout and retry logic that prevents indefinite hangs and duplicate side effects.
Detecting Timeouts with AbortController
The RealWorld test suite demonstrates how to guarantee the UI never waits indefinitely for a stalled backend. In specs/e2e/helpers/api.ts, the harness creates an AbortController instance for every fetch, pairing it with setTimeout to cancel the request after a configurable deadline.
You should centralize the timeout value—commonly DEFAULT_TIMEOUT_MS = 10_000—in a single constants module, mirroring the global configuration found in specs/e2e/playwright.base.ts (line 32). This ensures every API call shares the same 10-second ceiling without scattering magic numbers throughout your codebase.
// Constants centralized as shown in the Playwright base config pattern
export const DEFAULT_TIMEOUT_MS = 10_000;
export const MAX_RETRIES = 3;
export const BASE_BACKOFF_MS = 250;
Implementing Intelligent Retry Logic
The backend error-handling specification (apps/documentation/src/content/docs/specifications/backend/error-handling.md) mandates that clients retry only transient failures—specifically HTTP 5xx responses and network-level AbortError exceptions—while treating 4xx client errors as permanent failures requiring user intervention.
This distinction prevents endless retry loops against broken requests. The specs/e2e/helpers/api.ts helper implements a capped retry loop that inspects the error type and status code before deciding whether to attempt another request.
Exponential Backoff and Jitter
To avoid thundering-herd effects on a recovering server, the specification recommends exponential backoff with jitter. After each failed attempt, wait baseDelay * 2^n + randomJitter milliseconds before the next retry, where n is the attempt count.
This calculation spreads retry traffic over time and reduces load spikes. The test harness logs each retry attempt, allowing you to tune the BASE_BACKOFF_MS (typically 250 ms) based on observed latency patterns.
Idempotent Request Selection
Apply retries only to idempotent HTTP verbs—GET, HEAD, and OPTIONS—or to POST/PUT requests that include unique idempotency keys. The error-handling documentation explicitly warns against blind retries of non-idempotent operations to prevent duplicate side effects such as double-posting comments or processing payments twice.
Building a Resilient Fetch Wrapper
Consolidate these strategies into a reusable TypeScript module that your components import. This wrapper combines AbortController timeouts, transient-error detection, and exponential backoff into a single async function.
// src/client/apiClient.ts
import { DEFAULT_TIMEOUT_MS, MAX_RETRIES, BASE_BACKOFF_MS } from './constants';
/**
* Performs a fetch with timeout and exponential‑backoff retries.
*
* @param input Request URL or Request object
* @param init fetch init options (method, headers, body …)
* @returns JSON‑parsed response body
*/
export async function apiFetch<T>(
input: RequestInfo,
init?: RequestInit
): Promise<T> {
for (let attempt = 0; attempt <= MAX_RETRIES; ++attempt) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
try {
const response = await fetch(input, {
...init,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Only retry on network errors or server‑side failures
if (!response.ok && response.status >= 500) throw new Error('5xx');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as T;
} catch (err: any) {
clearTimeout(timeoutId);
// AbortError signals a timeout – treat it as transient
const isTransient = err.name === 'AbortError' || err.message === '5xx';
if (!isTransient || attempt === MAX_RETRIES) {
// No more retries – bubble the error up to the UI layer
throw err;
}
// Exponential back‑off with jitter
const backoff = BASE_BACKOFF_MS * 2 ** attempt;
const jitter = Math.random() * 100;
await new Promise((r) => setTimeout(r, backoff + jitter));
}
}
// Should never reach here
throw new Error('Unexpected exit from retry loop');
}
The wrapper checks err.name === 'AbortError' to detect timeouts and response.status >= 500 to identify server-side hiccups, ensuring 4xx errors propagate immediately without wasting retry budget.
Surfacing Errors in the UI
When the retry limit exhausts, the UI must display a clear message and offer a manual retry path. The frontend tests specification (apps/documentation/src/content/docs/specifications/frontend/tests.md) requires implementations to show "Network error – please try again" rather than silent failures.
// src/pages/ArticlePage.tsx (React example)
import { apiFetch } from '../client/apiClient';
import { useEffect, useState } from 'react';
export function ArticlePage({ slug }: { slug: string }) {
const [article, setArticle] = useState<Article | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<Article>(`/api/articles/${slug}`)
.then(setArticle)
.catch((e) => setError(e.message));
}, [slug]);
if (error)
return (
<div className="error">
Network error – {error}.{' '}
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
if (!article) return <div>Loading…</div>;
return (
<article>
<h1>{article.title}</h1>
{/* … */}
</article>
);
}
This pattern keeps users informed and in control, matching the behavior expected by the RealWorld e2e test suite.
Instrumentation and Logging
The specs/e2e/helpers/api.ts test harness logs every retry attempt, timeout, and final outcome to the console. Production clients should replicate this instrumentation, sending telemetry to your logging service so you can identify flaky endpoints and adjust MAX_RETRIES or BASE_BACKOFF_MS based on real-world latency distributions.
Summary
- Enforce hard timeouts using
AbortControllerwith a centralized 10-second default, as configured inspecs/e2e/playwright.base.ts. - Retry selectively on transient 5xx responses and
AbortErrornetwork failures only, per the backend error-handling specification. - Apply exponential backoff with jitter (
baseDelay * 2^n + randomMs) to prevent overwhelming recovering servers. - Restrict retries to idempotent requests (
GET,HEAD,OPTIONS) or uniquely keyed mutations to avoid duplicate side effects. - Surface clear UI feedback when retries exhaust, offering manual retry controls as documented in the frontend tests specification.
- Instrument every attempt to tune resilience parameters based on observed backend behavior.
Frequently Asked Questions
How does the RealWorld specification recommend detecting request timeouts?
According to the specs/e2e/helpers/api.ts test harness and backend error-handling documentation, clients should instantiate an AbortController for each request and use setTimeout to trigger controller.abort() after a configurable deadline—typically 10 seconds. This converts hung connections into catchable AbortError exceptions that the retry logic can treat as transient failures.
Which HTTP status codes should trigger a retry in RealWorld clients?
The backend error-handling specification (apps/documentation/src/content/docs/specifications/backend/error-handling.md) dictates that retries should only occur on HTTP 5xx server errors and network-level failures such as AbortError. HTTP 4xx client errors indicate permanent request issues (e.g., validation failures) and must not trigger automatic retries.
What backoff strategy does RealWorld recommend for API retries?
The specification recommends exponential backoff with jitter, calculated as BASE_BACKOFF_MS * 2^attempt + randomJitter. This formula—demonstrated in the e2e test helpers—distributes retry traffic over time to avoid thundering-herd effects and gives the backend breathing room to recover from temporary load spikes.
How can RealWorld clients safely retry POST requests without causing duplicate side effects?
The error-handling documentation advises applying retries only to idempotent HTTP verbs (GET, HEAD, OPTIONS) unless the POST or PUT request includes a unique idempotency key (such as a client-generated request ID) that allows the server to recognize and deduplicate repeated submissions. Without this key, retrying a non-idempotent request risks double-posting comments or processing duplicate transactions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →