Retryable vs non-retryable errors
Not every error should be retried. Retrying a non-idempotent operation on failure can cause duplicates — double charges, duplicate records, sent emails sent twice.
| error type | retry? | examples |
|---|---|---|
| Transient network error | yes | ECONNRESET, ETIMEDOUT, socket hang up |
| 503 Service Unavailable | yes | Server overloaded, temporarily down |
| 429 Too Many Requests | yes | Rate limited — retry after Retry-After header |
| 500 Internal Server Error | sometimes | Only if the operation is idempotent |
| 400 Bad Request | no | The request itself is wrong; retrying won't help |
| 401 / 403 | no | Auth error; retrying without fixing auth will fail again |
| 404 Not Found | no | The resource doesn't exist |
Why naive retries make things worse
Retrying immediately at a fixed interval — the naive approach — causes the thundering herd problem. When a service goes down, all clients fail at the same moment, then all retry at the same moment, creating a synchronized burst that overwhelms the recovering service.
// Bad: immediate retry, fixed interval async function fetchWithBadRetry(url) { for (let i = 0; i < 3; i++) { try { return await fetch(url); } catch { await sleep(1000); } // every client waits 1s, then all hit at once } throw new Error('max retries exceeded'); }
Backoff with full jitter
Exponential backoff doubles the delay on each attempt, spreading retries out in time. Adding random jitter prevents clients from synchronizing even if they failed at the same moment.
// Good: exponential backoff with full jitter async function fetchWithRetry(url, { maxAttempts = 3, baseMs = 100 } = {}) { for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await fetch(url); } catch (err) { if (attempt === maxAttempts - 1) throw err; // Full jitter: random value in [0, base * 2^attempt] const cap = baseMs * Math.pow(2, attempt); const delay = Math.random() * cap; await sleep(delay); } } } // attempt 0: delay in [0, 100ms] // attempt 1: delay in [0, 200ms] // attempt 2: delay in [0, 400ms]
| strategy | thundering herd | mean wait | recommendation |
|---|---|---|---|
| No backoff | severe | 0ms | Never use |
| Fixed backoff | synchronized | base | Avoid for shared services |
| Exponential (no jitter) | still synchronized | base * 2^n | Better but not enough |
| Full jitter | eliminated | base * 2^(n-1) | Recommended |
| Decorrelated jitter | eliminated | slightly higher | Good alternative |
Testing retry logic with realistic failures
slowdep's errorRate option makes it easy to trigger failures reliably in tests, without fragile mocking.
import { withLatency } from 'slowdep'; // 50% error rate — reliably exercises retry logic const unreliableFetch = withLatency(fetch, { p50: 80, p99: 1000, errorRate: 0.5, }); test('retries up to 3 times before giving up', async () => { let callCount = 0; const trackedFetch = async (...args) => { callCount++; return unreliableFetch(...args); }; await expect( fetchWithRetry('https://api.example.com', { maxAttempts: 3 }) ).rejects.toThrow(); expect(callCount).toBeLessThanOrEqual(3); });
Set errorRate: 1.0 to make every call fail. This tests your max-retry limit and deadline enforcement without relying on probability.