Back in 2019, we pushed a tiny origin timeout change — 200ms to 500ms — thinking we were being generous. Thirty minutes later, half our North American edge PoPs were shedding traffic and origins were drowning in retries. It wasn't the origins that failed us; we failed to stop the edge from hammering them. That night taught me that CDN fault tolerance isn't about making origins invincible, it's about teaching the edge when to stop asking.
When Retries Become the Outage: Anatomy of an Edge-Origin Cascade
Most engineers picture a cascade failure as an origin going dark, but at the edge it usually starts while the origin is still technically alive. You’ve got 300 PoPs, each with dozens of servers, all seeing elevated TTFB at the same time. Without a circuit breaker, every cache miss spawns a retry, then a revalidation, then a thundering herd on the next MISS. I’ve watched a single slow API endpoint — 800ms p95 instead of the usual 90ms — turn into 40k RPS of origin load because our edge logic was “helpfully” retrying on 502 and 504. We didn’t have an origin outage, we manufactured one.
The math is brutal and predictable. If your edge concurrency is 10k requests per second and your origin starts shedding 30% of traffic with timeouts, a naive retry policy with just one retry effectively pushes 13k attempts per second at an origin that can barely handle 7k. Add in stale-while-revalidate or collapsed forwarding that breaks, and you’re not just amplifying load — you’re synchronizing it. That’s why cascade prevention has to live on the data plane, not in some central control plane that learns about the problem 30 seconds too late. We learned to treat this as an SRE Practices for CDN: Applying Site Reliability Engineering to Content Delivery problem: define failure narrowly, detect it locally, and act within milliseconds.
What you’re really building is edge resilience against partial degradation. Don’t wait for HTTP 500s. Timeouts, TCP connection failures, TLS handshake errors, and even 429s from an overwhelmed origin are all signals that should count toward your breaker’s failure budget. I now count any origin response that takes longer than our p99 SLO as a half-failure, because latency is often the earliest sign of collapse. The HTTP semantics around retryability and cacheability documented in the HTTP Working Group Specifications are useful here — they’ll remind you why blindly retrying idempotent GETs isn’t free when you’ve got a global fleet doing it in lockstep.
// Edge circuit breaker - per-origin state in shared memory (Rust/Wasm edge worker)
struct EdgeBreaker {
state: AtomicU8, // 0=Closed, 1=Open, 2=HalfOpen
failures: SlidingWindowCounter, // 10s window, 100 buckets
success_probes: AtomicU8,
}
impl EdgeBreaker {
fn should_allow(&self) -> bool {
match self.state.load(Ordering::Relaxed) {
0 => true, // Closed - all traffic flows
1 => {
// Open - check if cool-down elapsed (e.g., 30s)
if self.cooldown_expired() {
self.state.compare_exchange(1, 2, Ordering::SeqCst, Ordering::Relaxed).ok();
true // Allow one probe into Half-Open
} else {
false // Fast-fail: serve stale or 503 + Retry-After
}
},
2 => self.success_probes.load(Ordering::Relaxed) < 3, // Half-Open: limited probes
_ => false
}
}
fn record(&self, result: OriginResult) {
// Timeouts and 5xx count as 1.0, p99 latency breaches as 0.5
let weight = match result {
OriginResult::Timeout | OriginResult::Status5xx => 1.0,
OriginResult::Slow(p99_breach) if p99_breach => 0.5,
_ => 0.0
};
self.failures.add(weight);
self.evaluate_threshold(); // e.g., 50% failure rate over 10s -> Open
}
}
Building a Three-State Breaker That Actually Works at the Edge
Forget the textbook circuit breaker you’d use inside a microservice. At the CDN edge you can’t afford coordination, you can’t store much state, and you must make a decision in under a millisecond. We run a classic three-state machine — Closed, Open, Half-Open — but we shard it per PoP, per origin hostname, and sometimes per URL pattern. A checkout API gets a far more aggressive threshold than a static image origin. That scoping is the difference between protecting one origin and accidentally tripping the breaker for everything behind it.
In Closed state, we’re counting failures in a sliding window rather than a simple counter. I’ve moved away from fixed 60-second windows; they’re too slow to trip and too slow to recover. Our sweet spot is a 10-second rolling window with 100 buckets, rolling every 100ms. If the weighted failure rate crosses 50% and we’ve seen at least 200 requests in that window, we open. The request minimum prevents a single PoP with low traffic from flapping open on three stray 503s. When we do open, we don’t just return errors — we serve stale from cache if we have it, or synthesize a 503 with a precise Retry-After: 15 and stale-if-error semantics that browsers and downstreams respect, which aligns with guidance you’ll find in web.dev Articles on resilient loading patterns.
Half-Open is where most edge implementations get lazy and break. Don’t just flip back to Closed after a timer expires. We allow exactly three probe requests through after a 30-second cool-down, and they must be staggered — one every 500ms. If two of three succeed within our latency SLO, we close. If any probe times out, we reopen immediately with an exponential backoff: 30s, 60s, 120s, capped at 5 minutes. That jitter and backoff is critical; without it, 200 PoPs all probing at the same second will create the very cascade you were trying to prevent. This probe logic ties directly into how we think about CDN Error Budget Management: Balancing Reliability with Feature Velocity — every false probe burns budget we’d rather spend serving stale gracefully.
-- OpenResty / NGINX edge Lua: fast-fail before origin fetch
local breaker = require "edge.breaker"
local origin_key = "api.origin.example.com:/v2/checkout"
local state = breaker.get_state(origin_key)
if state == "OPEN" then
-- Don't touch the origin. Serve stale or fail fast.
local stale = cache.lookup_stale(ngx.var.request_uri, 300) -- 5m stale window
if stale then
ngx.header["X-Cache-Status"] = "STALE-CIRCUIT-OPEN"
ngx.header["Retry-After"] = "30"
return stale:serve()
end
ngx.status = 503
ngx.header["Retry-After"] = "30"
ngx.say("Origin circuit open - edge shedding load")
return ngx.exit(503)
end
if state == "HALF_OPEN" and not breaker.allow_probe(origin_key) then
-- Another worker is already probing, shed this request
return serve_stale_or_503()
end
-- Proceed to origin, record result asynchronously
local res, err = fetch_origin()
breaker.record_result(origin_key, {
status = res and res.status or 0,
latency_ms = res and res.ttfb or 5000,
is_timeout = (err == "timeout")
})
Tuning Thresholds With Real Production Signals
There’s no universal threshold that works for every origin, and copying defaults from a library will hurt you. For our edge cache fleet, we ended up with very different settings for dynamic APIs versus static origins, and we re-tune them quarterly. The biggest mistake I see teams make is setting failure thresholds too high — “we’ll open at 80% errors so we don’t flap.” By the time you hit 80% 5xx at the edge, your origin has been on fire for a full window and you’ve already amplified the failure. We trip far earlier on latency, and we bias toward opening quickly and recovering cautiously.
We also learned to track success differently. It’s not enough to count 200 OKs. A 200 that took 4 seconds is a failure for an edge SLO where we promise 150ms TTFB. That’s why our breaker records weighted failures: full weight for timeouts and 5xx, half weight for responses over our p99 latency objective. And we never run breakers in isolation from observability — every state transition emits a counter and a trace event so we can see when a PoP is flapping. If you’re still doing manual threshold changes via tickets, you’re burning hours you shouldn’t be; we automated rollbacks tied to error budget burn, a pattern we documented in our CDN Toil Reduction Through Automation: Eliminating Repetitive Operations Work work.
Below is what we actually run in production today for two origin classes. These aren’t theoretical — they’re from a 90-day window across ~180 PoPs serving ~12 Tbps peak. Notice how the dynamic API breaker is far more sensitive and has a shorter half-open probe budget, because the cost of hammering a stateful checkout origin is much higher than revalidating a static asset.
| Origin Class | Failure Threshold (10s window) | Min Requests to Trip | Open Cool-down | Half-Open Probe Rule |
|---|---|---|---|---|
| Dynamic API (checkout, cart) | 50% weighted failures | 200 reqs / PoP | 30s + exponential to 300s | 3 probes, 2 must succeed <250ms |
| Static / Image Origin | 70% weighted failures | 500 reqs / PoP | 15s fixed | 5 probes, 3 must succeed <500ms |
| Video Segment Origin | 60% failures or p95 >1.2s | 300 reqs / PoP | 20s + exponential to 120s | 2 probes, both <800ms |
| Third-Party Personalization | 40% failures | 100 reqs / PoP |
Putting the Breaker in the Hot Path: Edge Worker ImplementationYou don't want your circuit breaker living in a sidecar that adds 15ms of latency on every request. At the edge, it has to run inline — inside the worker process that’s already handling the fetch — and it has to share state across workers on the same PoP. That’s where most DIY implementations fall apart. They store counters in local memory, so worker #3 trips while worker #12 is still happily hammering a dying origin. You’ve got 24-48 workers per edge node, you need a shared view. We've standardized on a shared memory dictionary (lua_shared_dict in OpenResty / ngx.shared.DICT in Cloudflare Workers with Durable Objects, or shared memory in Varnish) with a rolling window. Don't use a naive fixed window — you'll get thundering herds on window rollover. A 10-second sliding window with 1-second buckets smooths it out enough without eating too much memory. Our production defaults: if >30 requests in the window and >50% are 5xx or origin timeouts (>2s TTFB), we OPEN the breaker for 30 seconds. That’s conservative enough to avoid flapping on a single bad PoP but fast enough to stop a retry storm. And critically, we track 5xx and timeouts separately from 429s — you don't want to circuit-break on rate limiting.
Advanced Patterns: Bulkheads, Stale Fallbacks, and Debugging a Flapping BreakerThe basic three-state model isn’t enough when one origin serves 400 different hosts. If you use a single global breaker per origin IP, one noisy customer will take down everyone else. We learned this the hard way when a single misconfigured WordPress site started returning 100% 500s and opened the breaker for the entire origin cluster. Now we isolate by bulkhead: breaker key is What you do when the breaker is open matters just as much. Returning a hard 503 is honest, but it’s a terrible user experience if you’ve got a stale copy. Our fallback hierarchy is: 1) serve stale from cache with If your breaker is flapping — opening and closing every 30 seconds — you’ve almost certainly tuned it too tight. The usual culprits: error threshold below 30%, MIN_REQ too low (under 20), or you’re counting 429s and 503s from downstream rate limiters as origin failures. Check your PoP-level logs and graph Frequently Asked QuestionsShould I use status codes or latency to trigger the breaker?Both, but weight them differently. 5xx and connect timeouts are hard failures — count them immediately. For latency, don't trip on a single slow request. We only count requests where TTFB > 2x your p95 origin latency (for us that's >800ms) and only if 60%+ of the window is slow. Otherwise you'll breaker on normal origin GC pauses. Where should circuit breaker state live — locally on each PoP or globally?Keep it PoP-local. A global breaker (via Redis or KV) adds 20-50ms of coordination latency and creates a new single point of failure. An origin can be unhealthy from AMS but perfectly fine from IAD due to routing or partial failure. Local state gives you faster reaction (sub-millisecond lookup) and natural isolation. Replicate state globally only for observability, not for decision-making. How do I prevent a thundering herd when the breaker goes half-open?Allow only one probe request per second per breaker key, not all requests. Use an atomic add with a 1-second TTL as a probe lock — like in the code above. If that probe succeeds, close the circuit; if it fails, re-open for another 30s with exponential backoff (30s, 60s, 120s cap). Never let 1,000 waiting requests all retry at once. What's a safe starting point for thresholds if I have no production data yet?Start conservative: 10-second window, minimum 30 requests, 50% error rate to OPEN, 30-second open duration, and one probe per second in HALF_OPEN. Monitor for a week before tightening. If you see zero trips, you might be too lenient. If you see flapping, loosen the error rate to 60% or increase MIN_REQ to 50. There's no universal number — your origin's normal error baseline is what matters. External ReferencesRelated Articles
References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker |