Last Black Friday at 02:14 AM, our primary origin pool went soft — not hard-down, just slow. P99 origin response times crept from 120ms to 2.5 seconds, and within 40 seconds our retry logic turned a manageable brownout into a full stampede. I've seen that pattern more times than I care to admit over the last decade, and it always starts the same way: well-intentioned retries with no backoff, no jitter, and no budget. If your CDN retries aren't deliberately designed to back off, they’ll finish what the outage started.
Why Immediate Retries Turn a Blip Into an Outage
When an origin returns a 502, 503, or just times out, the instinct is to retry right away. It feels harmless — one more request, maybe it was a fluke. At CDN scale, though, that single retry multiplies. If you're serving 80k RPS through an edge PoP and 5% of origin requests start failing, an immediate retry instantly adds 4k RPS back to an origin that's already struggling. I've debugged incidents where three layers of retries (edge, shield, and mid-tier) turned a 5% error rate into a 16% increase in origin load in under ten seconds.
The core issue is correlation. Failures aren't random; they're clustered. A database failover, a saturated load balancer, or a bad deploy affects thousands of requests at once. If every edge node retries after a fixed 500ms delay, those retries arrive in perfect sync — the classic thundering herd. Your origin recovers for a half-second, then gets hit again by a synchronized wave. This is why health checking alone can't save you. Even with solid Origin Health Monitoring Patterns: Active Probes, Passive Checks, and Hybrid Approaches, your probe may mark the origin healthy right as the retry storm lands.
You also have to be ruthless about *what* you retry. Retrying non-idempotent POSTs or requests that failed due to malformed origins just adds load for no benefit. Per the HTTP Working Group Specifications, only idempotent methods and specific status codes should be considered retryable. In practice, that means GET, HEAD, OPTIONS, PUT, DELETE on 502/503/504 and connect timeouts — never on 4xx (except 429 with Retry-After) and never blindly on 500 without understanding your application semantics.
# Bad: Fixed-interval retry - will synchronize across PoPs
# Don't do this in VCL / Envoy / Nginx
# Fixed 500ms retry - synchronized thundering herd
retry_policy:
num_retries: 3
retry_on: "5xx,connect-failure"
per_try_timeout: 2s
# implicit fixed delay = disaster
# Better: Exponential backoff with cap
retry_policy:
num_retries: 2
retry_on: "502,503,504,connect-failure"
retriable_status_codes: [502, 503, 504]
per_try_timeout: 2s
host_selection_retry_max_attempts: 2
backoff:
base_interval: 100ms
max_interval: 2s
multiplier: 2.0
Building Exponential Backoff That Respects Both User and Origin
Exponential backoff isn't just "wait longer each time" — it's about giving the origin breathing room while keeping user-perceived latency sane. I typically start with a 100ms base and a 2x multiplier, capped at 1-2 seconds. So your delays go 100ms, 200ms, 400ms, 800ms, capped. For user-facing GETs, you can't afford more than two retries or you'll blow your edge timeout budget. We tie our retry timeout to our overall SLA: if the edge must respond in 3 seconds, and origin p95 is 300ms, you only have room for one or two backs-off attempts before you must serve stale or error.
The mistake I see teams make is setting the base too low or the cap too high. A 20ms base does nothing — the origin hasn't recovered in 20ms. A 10-second cap, on the other hand, holds edge connections open and burns through your Origin Connection Pooling Optimization: Reducing TCP Overhead at CDN Scale capacity. Those pooled connections are finite, and a slow retry holds a socket far longer than a fast failure. In one incident we tracked, raising the max backoff from 2s to 5s increased origin connection churn by 30% because edge workers were blocked waiting to retry instead of recycling connections.
You also need to differentiate between connection failures and HTTP errors. A connect timeout or TLS handshake failure is a great candidate for an immediate retry to a *different* origin host, not a backoff to the same one. But a 503 with a Retry-After header should be honored, and a 504 gateway timeout suggests the origin is overloaded — that's when backoff matters most. If you're already using Origin Request Hedging: Sending Parallel Requests to Cut Tail Latency for tail latency, be extra conservative with retries; hedging plus retries can double your origin amplification if they're not coordinated.
import random
import time
def origin_retry_with_backoff(
attempt: int,
base_ms: int = 100,
multiplier: float = 2.0,
max_ms: int = 2000,
use_full_jitter: bool = True
) -> float:
# Exponential backoff: base * 2^attempt, capped
exp_backoff = min(max_ms, base_ms * (multiplier ** attempt))
if use_full_jitter:
# Full jitter: random(0, exp_backoff) - best for high contention
delay_ms = random.uniform(0, exp_backoff)
else:
# Equal jitter: (exp_backoff / 2) + random(0, exp_backoff / 2)
delay_ms = (exp_backoff / 2) + random.uniform(0, exp_backoff / 2)
# Never retry more than 2 times for user-facing requests
# and only on idempotent methods / retriable status codes
time.sleep(delay_ms / 1000.0)
return delay_ms
# Usage: only retry on idempotent methods and specific errors
RETRIABLE = {502, 503, 504}
if response.status in RETRIABLE and request.method in ("GET", "HEAD", "PUT"):
if attempt < 2:
delay = origin_retry_with_backoff(attempt)
Adding Jitter and Enforcing a Hard Retry Budget
Backoff without jitter just creates synchronized waves that are spaced further apart. You’ll still see sharp spikes in origin QPS every 100ms, 200ms, 400ms. Jitter randomizes that. I've had the best luck with full jitter for origin protection — `delay = random(0, exp_backoff)` — because it spreads the load evenly. Equal jitter or decorrelated jitter can work too if you want more predictable tail latency, but as web.dev Articles on resilience patterns point out, randomness is your friend when thousands of clients contend for the same recovering resource. The key is to ensure your jitter is per-request and seeded properly; using a static or per-PoP seed just moves the herd, it doesn't break it.
Jitter alone isn't enough, though. You need a retry budget — a hard limit on what percentage of traffic can be retried. This is the single most effective control I've deployed. Instead of "retry every failed request up to N times," you say "retries may not exceed 20% of successful origin requests in any 10-second window." If you serve 10k successful origin requests in 10s, you're allowed 2k retries, period. Once the budget is exhausted, you fail fast and serve stale from cache or a degraded response. Envoy and many custom stacks implement this as a token bucket. It prevents a 50% origin failure from generating a 100%+ retry flood.
Tuning that budget is where the trade-offs show up. Too generous (say, 50%) and you don't protect the origin during a major incident. Too strict (5%) and you'll see more user-visible errors during small blips that *would* have recovered with a retry. We run at 20% in steady state and drop to 10% automatically when origin health scores degrade — that coupling between health monitoring and retry budgets is critical. We've also learned to track retry amplification as a first-class metric, not just error rates. If your retry-to-origin-request ratio climbs above 0.3, you're actively making things worse.
| Strategy Configuration | Origin QPS Amplification | P95 Client Latency | Origin Recovery Time |
|---|---|---|---|
| Immediate Retry (3x, no backoff) | +185% during 5% failure | 620ms | 42s (herd collapse) |
| Fixed 500ms Delay (2x) | +92% (synchronized spikes) | 880ms | 28s |
| Exponential 100ms-2s + Full Jitter (2x) | +22% (smoothed) | 410ms | 9s |
| Exponential + Jitter + 20% Budget | +18% (capped) | 365ms | 6s |
| Exponential + Jitter + 10% Budget (degraded mode) | +9% (strict cap) | 340ms | 4s |
Putting It Together: Production-Ready Implementation at the Edge
Where you implement retry logic matters as much as how you implement it. Don't bury it in your origin application — by then you've already paid the cost of a full round-trip through the CDN. The best place is at the edge itself: in a VCL subroutine, an Edge Worker, or your layer-7 load balancer. That way you can fail fast, apply jitter locally, and avoid tying up origin connections that should be serving fresh traffic. We've seen teams cut their p95 origin latency by 40% just by moving retries from the origin to the edge and enforcing a strict budget there.
Here's the recipe we run in production for most API and dynamic content workloads: Start with a base delay of 75-100ms, double it each attempt (exponential factor of 2), cap any single backoff at 1,500ms, and never exceed 3 total attempts including the initial try. That gives you a sequence like ~100ms, ~200ms, ~400ms with jitter applied. Crucially, wrap the whole thing in a total timeout. If your Origin Timeout Configuration Guide: Connect, Read, and Total Timeout Tuning sets a 3-second total timeout, your retry loop must respect it — no point backing off for 2 seconds if the client gave up at 1.5s. And only retry idempotent methods (GET, HEAD, OPTIONS) by default. Retrying a POST without a de-duplication token is how you double-charge a customer.
You'll also want observability from day one. Emit a counter for origin.retry.attempt with labels for attempt number, origin PoP, and final status. If your retry rate suddenly jumps from 0.5% to 8% on a single PoP, that's not normal variance — that's an origin or network partition you need to isolate. Log the calculated backoff value before you sleep, so you can later verify your jitter distribution is actually uniform and not clustering.
// Production-ready edge retry with exponential backoff, full jitter, and budget
async function fetchWithRetry(request, originUrl, options = {}) {
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 100;
const MAX_DELAY_MS = 1500;
const TOTAL_BUDGET_MS = 2800;
const JITTER_FACTOR = 0.3; // +/- 30%
const startTime = Date.now();
let lastError;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000);
const response = await fetch(originUrl, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
// Only retry on retryable status codes
if (response.status < 500 || response.status === 501) {
return response;
}
lastError = new Error(`Retryable status: ${response.status}`);
} catch (err) {
// Don't retry on non-retryable errors like SSL verification failures
if (err.message.includes('certificate') || err.name === 'AbortError') {
throw err;
}
lastError = err;
}
if (attempt === MAX_RETRIES) break;
if (Date.now() - startTime >= TOTAL_BUDGET_MS) break;
// Exponential backoff with decorrelated jitter
const exponential = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * Math.pow(2, attempt));
const jitter = exponential * JITTER_FACTOR * (Math.random() * 2 - 1);
const delay = Math.min(exponential + jitter, TOTAL_BUDGET_MS - (Date.now() - startTime));
if (delay <= 0) break;
await new Promise(r => setTimeout(r, delay));
}
throw lastError;
}
Beyond Basic Retries: Circuit Breakers and Diagnosing Retry Storms
Backoff and jitter will save you from a brief blip, but they won't save you from a truly sick origin. That's where a circuit breaker comes in. Think of it as a fuse for your retry budget. We typically configure ours to trip open if more than 50% of requests to a specific origin IP fail for 30 consecutive seconds, or if we see 100+ consecutive 5xx/timeout errors. Once open, we stop retrying that origin entirely for 60 seconds and serve stale-while-revalidate or fail over to a secondary origin. We then allow a single probe request through — if it succeeds, we half-close the circuit; if not, we stay open for another 60 seconds. Without this, even well-jittered retries become a slow, distributed DDoS against your own infrastructure during a prolonged outage.
When things do go wrong, your first clue is usually a metric, not a log. If your dashboard shows retry attempts climbing but success rate staying flat, you're in a retry storm. Check two things immediately. First, are you retrying on the wrong errors? We see this constantly: teams retry on 429 (Too Many Requests) or on TLS handshake failures. Retrying a 429 without respecting the Retry-After header just makes throttling worse, and retrying an SSL failure is pointless until you fix the cert — see our Origin SSL Verification at CDN Edges: Preventing Man-in-the-Middle Attacks for why you should fail closed there. Second, check your origin IP health. If one origin node is blackholed or its IP reputation has tanked, the edge will keep hammering it. Our Origin IP Reputation Management: Keeping Your CDN Source IPs Clean guide covers how to detect and rotate those IPs before the CDN's own health checks mark them dead.
A more advanced pattern to consider is hedged requests instead of sequential retries, but use it sparingly. With hedging, you send the initial request and, if there's no response within your p95 latency (say, 300ms), you send a second identical request to a different origin. Whichever returns first wins, and you cancel the other. It cuts tail latency beautifully for read-heavy workloads, but it doubles your origin load at the worst possible time if you hedge aggressively. We only enable hedging for GETs under 10KB where origin CPU is cheap, and we cap it at 5% of total traffic. For everything else, sequential backoff with a hard budget is safer and far more predictable to operate.
Frequently Asked Questions
How many retries is actually safe before I’m hurting my origin?
For most internet-facing origins, 2 retries (3 total attempts) is the sweet spot. That covers transient packet loss and a single overloaded node without turning a 2% error rate into a 6% load spike. Beyond 3 retries, you're rarely fixing anything — you're just delaying the inevitable 503 for the user and holding edge connections open. If 3 attempts don't succeed, trip a circuit breaker and fail over or serve stale.
Should I retry on every 5xx or just 502/503/504?
Retry only on 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout, and on network-level failures like connect timeouts and connection resets. Don't retry 500 Internal Server Error by default — it often indicates a deterministic application bug that will fail again identically. And never retry 501 Not Implemented. If you must retry 500s, do it only once and only for idempotent GETs.
Does jitter really matter if I only have a few edge nodes?
Absolutely. Even with 5 edge PoPs, a synchronized origin blip (like a 2-second deploy restart) will cause all PoPs to retry at the exact same intervals without jitter. That creates a thundering herd that can push a recovering origin from 30% CPU straight back to 100%. A simple full jitter of +/- 30% spreads that retry load across a 200-400ms window and is often the difference between a 3-second recovery and a 30-second cascading failure.
Can I safely retry POST or PATCH requests?
Only if they're explicitly idempotent. If the request includes an Idempotency-Key header and your origin honors it, you can retry with the same key. Otherwise, don't. We've seen double order placements and duplicate webhook deliveries from blind POST retries. A safer pattern is to make the client responsible for retrying non-idempotent writes, while the CDN only retries safe, read-only methods at the edge.