when to retry

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 typeretry?examples
Transient network erroryesECONNRESET, ETIMEDOUT, socket hang up
503 Service UnavailableyesServer overloaded, temporarily down
429 Too Many RequestsyesRate limited — retry after Retry-After header
500 Internal Server ErrorsometimesOnly if the operation is idempotent
400 Bad RequestnoThe request itself is wrong; retrying won't help
401 / 403noAuth error; retrying without fixing auth will fail again
404 Not FoundnoThe resource doesn't exist
the wrong way

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');
}
exponential backoff

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]
strategythundering herdmean waitrecommendation
No backoffsevere0msNever use
Fixed backoffsynchronizedbaseAvoid for shared services
Exponential (no jitter)still synchronizedbase * 2^nBetter but not enough
Full jittereliminatedbase * 2^(n-1)Recommended
Decorrelated jittereliminatedslightly higherGood alternative
testing with slowdep

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);
});
Always-fail mode

Set errorRate: 1.0 to make every call fail. This tests your max-retry limit and deadline enforcement without relying on probability.

further reading

Related topics