Retry
Re-attempt failed requests automatically with exponential backoff strategies.
In this article
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 often, and WiFi-to-mobile transitions cause brief outages. If every failure triggers an error dialog, mobile users see error messages over and over under normal network conditions.
Solution
With automatic retry logic, a request that fails with a transient error (5xx, timeout, network failure) is tried again before any error is shown. For a brief glitch, the retry usually succeeds and users never know anything went wrong.
Exponential backoff spaces the retries out: 1 second before the first retry, 2 seconds before the second, 4 before the third. That avoids hammering a struggling server while still retrying quickly the first time. Adding random jitter to each delay stops thousands of clients from retrying at the same moment and overloading a server that’s just recovering.
Not every error is worth retrying. A 404 means the resource doesn’t exist, so retrying won’t help. A 401 means you’re not authenticated, and retrying without fixing auth won’t change that. Retries belong on transient errors only, with a maximum retry count, because something that fails five times is probably permanent.
Example
These examples go from basic to more advanced: 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],
onRetry
} = options;
// One initial attempt plus up to maxRetries retries
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (response.ok) return await response.json();
const error = new Error(`Request failed: ${response.status}`);
error.retryable = retryableStatuses.includes(response.status);
throw error;
} catch (error) {
// fetch() rejects with a TypeError on network failure, which is worth retrying
const retryable = error.retryable ?? error instanceof TypeError;
if (!retryable || attempt === maxRetries) throw error;
// Exponential backoff with ±25% jitter
const exponentialDelay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
const jitter = exponentialDelay * 0.5 * (Math.random() - 0.5);
onRetry?.(attempt + 1);
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);
setError(null);
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 pass a threshold, so the app stops sending requests to a service it already knows is down.
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 the server rate-limits a request, its Retry-After header says how long to wait, and following it works better than your own backoff timing.
async function fetchWithRetryAfter(url, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url);
if (response.ok) return await response.json();
// Retry rate limits and server errors; other client errors fail straight away
const retryable = response.status === 429 || response.status >= 500;
if (!retryable || attempt === maxRetries) {
throw new Error(`Request failed: ${response.status}`);
}
let delay = Math.pow(2, attempt) * 1000;
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
// Retry-After can be seconds or HTTP date
delay = isNaN(retryAfter)
? Math.max(0, new Date(retryAfter) - Date.now())
: parseInt(retryAfter, 10) * 1000;
}
await new Promise(resolve => setTimeout(resolve, delay));
}
}
User Feedback During Retry
Telling users about retries lets them know the app is still working and hasn’t frozen.
function DataLoader({ url }) {
const [attempt, setAttempt] = useState(0);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
fetchWithRetry(url, {
maxRetries: 3,
onRetry: (attemptNum) => setAttempt(attemptNum)
}).then(setData, setError);
}, [url]);
if (error) return <div>Couldn't load this content. Please try again later.</div>;
if (!data) {
return (
<div>
Loading...
{attempt > 0 && <p>Connection issue, retrying (attempt {attempt})...</p>}
</div>
);
}
return <div>{data.content}</div>;
}
Benefits
- Users don’t see errors for network glitches that resolve within seconds.
- Mobile users stop seeing constant error messages, because brief drops on cellular connections get retried instead of reported.
- Brief unavailability during server deployments and infrastructure changes gets papered over automatically.
- Rate limiting becomes manageable when you respect
Retry-Afterheaders. - Read operations are easy to make reliable, since GET requests are safe to retry any number of times.
- Users no longer have to click “Try Again” for temporary problems.
Tradeoffs
- Error messages get delayed when there’s a real problem, so 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 overload servers that are trying to recover.
- Tuning parameters is tricky: retrying too aggressively adds load to a server that’s already struggling, and retrying too conservatively makes users wait too long.
- Retry logic can hide 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.
- Non-idempotent operations need care, because retrying a POST can create duplicates if the first request succeeded but the response was lost.
- Circuit breakers add complexity, since knowing when to stop retrying needs extra state.
- On metered mobile connections, failed retries still use data the user pays for.
Summary
Retry logic re-attempts failed requests that might succeed on a second try, hiding transient network issues from users. Exponential backoff with jitter keeps retries from overwhelming a recovering server, and retries belong only on operations that are safe to repeat. The goal is to make temporary failures invisible to users while failing fast on permanent errors.
See also
- Error Handling — Resilience
- Retry Pattern
- Exponential Backoff - Wikipedia