Retry
Problem
Your user’s WiFi glitched for 200 milliseconds and now they see an error message for something that would work fine if the app just tried again automatically.
This drives me crazy. Transient failures—network blips, server timeouts, deployment rollouts, container restarts—look identical to permanent failures in most applications. A 2-second hiccup gets the same treatment as “this resource doesn’t exist,” and users see errors for problems that would resolve themselves within seconds.
Mobile users have it worst. Cellular connections drop packets constantly, and WiFi-to-mobile transitions cause brief outages. If every failure triggers an error dialog, mobile users live in error-message hell for perfectly normal network conditions.
Solution
Implement automatic retry logic: when a request fails with a transient error (5xx, timeout, network failure), try again before showing an error. Most of the time the retry succeeds and users never know anything went wrong.
The key technique is exponential backoff—wait 1 second before the first retry, 2 seconds before the second, 4 before the third. This avoids hammering a struggling server while staying responsive. Add random jitter to prevent thousands of clients from retrying at the same time and overwhelming a recovering server.
Be smart about which errors you retry. A 404 means the resource doesn’t exist—retrying won’t help. A 401 means you’re not authenticated—retrying without fixing auth is pointless. Only retry genuinely transient errors, and set a maximum retry count; if something fails five times, it’s probably permanent.
Example
Here’s retry logic from basic to sophisticated: exponential backoff with jitter, circuit breakers, and respecting rate limit headers.
Exponential Backoff with Jitter
async function fetchWithRetry(url, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 30000,
retryableStatuses = [408, 429, 500, 502, 503, 504]
} = options;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (response.ok) return await response.json();
if (!retryableStatuses.includes(response.status)) {
throw new Error(`Non-retryable error: ${response.status}`);
}
throw new Error(`Retryable error: ${response.status}`);
} catch (error) {
if (attempt === maxRetries - 1) throw error;
// Exponential backoff with ±25% jitter
const exponentialDelay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
const jitter = exponentialDelay * 0.25 * (Math.random() - 0.5);
await new Promise(resolve => setTimeout(resolve, exponentialDelay + jitter));
}
}
}
React Hook with Retry
Wrapping retry logic in a hook provides a clean API for data fetching with automatic retries:
function useDataWithRetry(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
try {
const result = await fetchWithRetry(url, { maxRetries: 3 });
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, [url, retryCount]);
return { data, loading, error, retry: () => setRetryCount(c => c + 1) };
}
Retry with Circuit Breaker
A circuit breaker stops retrying entirely when failures exceed a threshold—preventing wasted requests to a known-broken service.
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.nextAttempt = Date.now();
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) throw new Error('Circuit breaker is OPEN');
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.failureCount = 0;
this.state = 'CLOSED';
return result;
} catch (error) {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
}
throw error;
}
}
}
Retry-After Header Support
When rate-limited, respect the server’s Retry-After header instead of using your own backoff timing.
async function fetchWithRetryAfter(url, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (response.ok) return await response.json();
if (response.status === 429 && attempt < maxRetries - 1) {
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
// Retry-After can be seconds or HTTP date
const delay = isNaN(retryAfter)
? new Date(retryAfter) - Date.now()
: parseInt(retryAfter) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
}
if (response.status >= 500) throw new Error(`Server error: ${response.status}`);
throw new Error(`Client error: ${response.status}`);
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
}
User Feedback During Retry
Show users what’s happening during retries so they know the app is working, not frozen.
function DataLoader({ url }) {
const [attempt, setAttempt] = useState(0);
const [data, setData] = useState(null);
useEffect(() => {
fetchWithRetry(url, {
maxRetries: 3,
onRetry: (attemptNum) => setAttempt(attemptNum)
}).then(setData);
}, [url]);
if (!data) {
return (
<div>
Loading...
{attempt > 0 && <p>Connection issue, retrying (attempt {attempt})...</p>}
</div>
);
}
return <div>{data.content}</div>;
}
Benefits
- Network glitches become invisible—users never see errors for problems that resolve in seconds.
- Mobile users stop suffering constant error messages; flaky cellular connections get handled gracefully.
- Your app survives server deployments and infrastructure changes because brief unavailability gets papered over automatically.
- Rate limiting becomes manageable when you respect
Retry-Afterheaders. - Read operations become bulletproof since GET requests are safe to retry any number of times.
- User friction drops significantly—no more manual “Try Again” clicks for temporary problems.
Tradeoffs
- Error messages get delayed when there’s a real problem—users wait through all retries before finding out something is broken.
- Without jitter, you create thundering herd problems where thousands of clients retry simultaneously and crush recovering servers.
- Tuning parameters is tricky: too aggressive wastes resources and worsens server problems; too conservative makes users wait too long.
- Retry logic can mask real infrastructure problems—if endpoints are flaky but retries eventually succeed, you might never notice.
- Delays grow quickly with exponential backoff; by the fifth retry (1s, 2s, 4s, 8s, 16s), users may think the app is frozen.
- Never retry non-idempotent operations carelessly—retrying a POST can create duplicates if the first request succeeded but the response was lost.
- Circuit breakers add complexity; knowing when to stop retrying requires additional state management.
- Mobile data costs add up—failed retries still consume bandwidth on metered connections.
Summary
Retry logic re-attempts failed requests that might succeed on a second try, hiding transient network issues from users. Use exponential backoff with jitter to avoid overwhelming recovering servers, and only retry operations safe to repeat. The goal: make temporary failures invisible while failing fast on permanent errors.