Circuit Breaking in CDN Load Balancers: Failing Fast to Protect the System

Back in 2018 I watched a single struggling origin in Frankfurt take down an entire European edge region — not because it failed, but because our load balancers wouldn't let it fail. Every timeout triggered three retries, each retry tied up a connection, and within ninety seconds we had 40 edge nodes all dog-piling the same sick server while healthy origins sat idle. That's the moment I stopped thinking of circuit breaking as a nice-to-have and started treating it as core load balancer protection.

Why Retries Without a Breaker Will Kill Your Edge

Most CDN load balancing strategies are built around distributing load, not containing failure. Whether you're using Weighted Round-Robin for CDN: Proportional Traffic Distribution Across Heterogeneous Servers or Least-Connections Load Balancing for CDN: Dynamic Distribution Based on Server Load, the default assumption is that an origin is either up or down. Reality's messier. Origins degrade — they get slow, they start returning 502s for 10% of requests, their p95 latency climbs from 80ms to 2 seconds. If your balancer keeps sending traffic at full rate, you don't have fault tolerance, you have a failure amplifier.

I've debugged this pattern more times than I'd like to admit, and it always looks the same in the metrics. Upstream concurrency spikes, active connections plateau at the pool limit, and response latency for *all* customers — even those who would have hit a healthy origin — starts climbing. That's because threads and connections are finite. A classic retry storm holds those resources hostage. Circuit breaking flips the logic: instead of asking "can I retry this?" it asks "should I even try this origin at all right now?" It's the fail fast principle applied at the balancer layer. When an origin crosses a failure threshold, we open the circuit and stop sending it traffic for a brief window, freeing up capacity for healthy paths.

This isn't just about protecting the origin — it's about protecting the balancer itself and the cascade that follows. Without it, one degraded PoP in your GSLB Implementation Patterns: Global Server Load Balancing for Multi-Region CDN setup can trigger cross-region failover that overloads the next region. The pattern is well documented in the HTTP Working Group Specifications around retry semantics — idempotent methods can be retried, but blind retries without circuit state ignore the underlying health signal entirely.

# Envoy-style circuit breaker for a CDN origin cluster
# Applied at the edge load balancer - not the origin
clusters:
- name: origin_video_fra
  connect_timeout: 0.25s
  circuit_breakers:
    thresholds:
      - priority: DEFAULT
        max_connections: 2048
        max_pending_requests: 1024
        max_requests: 4096
        max_retries: 3
        # Critical for fail fast behavior
        retry_budget:
          budget_percent: 20
          min_retry_concurrency: 5
  outlier_detection:
    consecutive_5xx: 5
    interval: 5s
    base_ejection_time: 30s
    max_ejection_percent: 50
    failure_percentage_threshold: 25
    enforcing_consecutive_5xx: 100
    enforcing_failure_percentage: 100

Tuning Thresholds That Actually Reflect Failure

The defaults will burn you. I've seen teams copy-paste a 50% error threshold from a blog post and then wonder why their breaker never opened during a partial outage where only 30% of requests were failing but latency was 4x normal. For CDN workloads, you can't rely on 5xx alone. You need to track latency, timeouts, and connection failures together. A good starting point we've used in production is a sliding window rather than a fixed counter — say, 100 requests or 10 seconds, whichever comes first — and we evaluate failure percentage *and* slow-call percentage in that window.

Here's what works for us now after a lot of painful tuning: we open the circuit if more than 25% of the window is errors *or* if more than 35% of calls are slower than our 99th percentile SLO (for us, 500ms for cache-miss fills). We don't use consecutive failures alone because one healthy response in a stream of failures can reset the counter and hide the problem. And we always define a separate, much lower threshold for connection failures — if we can't even establish a TCP connection 3 times in 10 seconds, that origin is isolated immediately. As the web.dev Articles on optimizing backend reliability point out, users feel tail latency far more than average latency, and circuit breakers are how you protect the tail.

The half-open state is where most implementations get lazy. Don't just wait 30 seconds and then flood the origin again. When the breaker moves to half-open, allow exactly one probe request — or a very small concurrency, like 2-3 requests — to test recovery. If that probe succeeds, close the circuit gradually. If it fails, reopen and back off exponentially. I've found 30s → 60s → 120s works well for origins behind a CDN; it's long enough to let an autoscale event or restart complete but short enough that you don't black-hole traffic for ten minutes.

// Simplified adaptive breaker logic for an edge LB (Go)
// Tracks rolling window failures + slow calls
type BreakerConfig struct {
  WindowSize          int           // requests per window
  FailureThreshold    float64       // e.g. 0.25
  SlowCallThreshold   float64       // e.g. 0.35
  SlowCallDuration    time.Duration // e.g. 500ms
  HalfOpenProbeLimit  int           // concurrent probes
  BaseBackoff         time.Duration // 30s
}

func (b *CircuitBreaker) shouldOpen(window WindowStats) bool {
  if window.Total < 50 {
    return false // not enough data
  }
  failureRate := float64(window.Failures) / float64(window.Total)
  slowRate := float64(window.SlowCalls) / float64(window.Total)
  
  // Connection failures are weighted heavily
  if window.ConnFailures >= 3 {
    return true
  }
  return failureRate > b.config.FailureThreshold || slowRate > b.config.SlowCallThreshold
}

func (b *CircuitBreaker) onHalfOpen() {
  // Allow only N probes, queue others to healthy origins
  b.semaphore = make(chan struct{}, b.config.HalfOpenProbeLimit)
}

How to Tell If Your Breaker Is Helping or Hiding Problems

You can't set a breaker and forget it. You need to see what it's doing, and you need to compare its impact against your SLOs. At a minimum, you should export four signals per origin pool: circuit state (closed/open/half-open), ejection percentage, probe success rate, and the latency delta between broken and healthy paths. We alert on circuits that stay open longer than 5 minutes — that's often a sign the threshold is correct but the origin needs human intervention — and we also alert on circuits that *never* open, because a breaker that never trips is probably misconfigured.

One mistake I've made is tuning for too much sensitivity. If your breaker opens on every tiny latency spike, you'll churn origins and create flapping that's worse than the original slowdown. That's why we shadowed our first implementation for two weeks — logging what *would* have been ejected without actually ejecting. That dry-run showed us our initial 100ms slow-call threshold was tripping on normal cache-miss variance. Bumping it to 500ms reduced false positives by 80% without missing real incidents.

When you're evaluating strategies, measure them under realistic load. Synthetic health checks aren't enough; they don't see the same failure modes as real user traffic with large headers, range requests, and varying object sizes. Here's what we've measured across different breaker styles in a production CDN handling ~2M RPS at the edge:

Strategy Window / Threshold Avg. Recovery Time P95 Latency During Incident Reduction in 5xx to Client
Consecutive 5xx Only (5 failures) Count-based, 5 errors 42s 1840ms 31%
Failure Percentage (25%) 100 req / 10s rolling 33s 1120ms 58%
Failure + Slow-Call (25% / 35% at 500ms) 100 req / 10s rolling 28s 620ms 74%
Adaptive with Half-Open Probes (2 probes) Exponential backoff 30s-120s 35s 540ms 81%

Wiring the Breaker Into the Data Path Without Taking Down the Fleet

Knowing your thresholds is one thing — enforcing them at line rate on every PoP is another. You don't want a breaker that lives in your control plane; it needs to sit in the hot path of your edge proxy, making decisions in microseconds, not milliseconds. Whether you're on Envoy, Nginx, or Varnish, the pattern's the same: track failures per upstream pool, trip fast, and shed load before your origin buckles.

At the edge, we've learned to isolate breakers per origin pool, not per proxy. A single misbehaving origin behind a Consistent Hash Ring Architecture for CDN: Minimizing Redistribution on Server Changes shouldn't trip the breaker for the entire ring. We run a 10-second rolling window with a minimum of 200 requests — anything less and you're just reacting to noise. If 50% of those requests return 5xx or hit a 2-second p99 timeout, we open the circuit for 30 seconds. That 30-second ejection is critical; shorter and you'll flap, longer and you'll blackhole recoverable capacity. During that open state, we don't just return 503s — we serve stale from cache if we have it, or fail over to a secondary origin.

Don't try to be clever with global state at first. Start with local, per-proxy breakers and let your outlier detection handle ejection. Envoy's outlier detection paired with circuit breakers is the most battle-tested combo we've run at 150k+ RPS per node. Here's the baseline we ship to every edge — tune from here, don't start from scratch:

# Envoy - per-cluster circuit breaking + outlier detection
circuit_breakers:
  thresholds:
    - priority: DEFAULT
      max_connections: 10000
      max_pending_requests: 5000
      max_requests: 8000
      max_retries: 3
      retry_budget:
        budget_percent: 20  # cap retries at 20% of active requests
        min_retry_concurrency: 5
outlier_detection:
  consecutive_5xx: 5
  interval: 10s
  base_ejection_time: 30s
  max_ejection_percent: 50
  failure_percentage_threshold: 50
  failure_percentage_request_volume: 200
  enforce_consecutive_5xx: true
  enforce_failure_percentage: true

# Nginx equivalent (plus)
# upstream origin_pool {
#   server origin1:443 max_fails=5 fail_timeout=30s;
#   server origin2:443 max_fails=5 fail_timeout=30s;
# }

Advanced Patterns and Why Your Breaker Might Be Lying to You

The basic open/half-open/closed state machine will save you from a full origin meltdown, but it won't save you from nuanced failures. The first advanced pattern you’ll need is bulkhead isolation. Don't let one bad content type or API route poison the whole origin pool. We've split breakers by route prefix — /api/* gets its own threshold from /static/*. When our image optimization origin started timing out at 4 seconds, it was tripping the breaker for all cache misses. Isolating it meant static assets kept flowing while we degraded gracefully on transforms. If you're pinning users with IP Hash for CDN Session Persistence: Consistent User Routing Without Cookies, you'll want per-hash-shard breakers too; a single hot shard shouldn't cascade.

The second pattern is adaptive half-open probing. A fixed 30-second sleep before testing the origin again is naive. Instead, use exponential backoff with jitter for your probes — 30s, then 60s, then 120s if it keeps failing — and send just one synthetic probe request, not live user traffic. Pair this with proper Health Check Design Patterns for CDN: Shallow, Deep, and Synthetic Checks. Your breaker shouldn't rely on passive traffic alone; an active deep health check that hits /healthz?deep=true and validates DB connectivity tells you way more than a 200 OK from Nginx. We've seen breakers stay closed because they were counting 200s from a load balancer health check endpoint that never actually hit the app.

If your dashboard shows your breaker tripping more than 15-20 times an hour, it's not protecting you — it's hiding a deeper problem. That's your cue to stop tuning the breaker and start investigating. Common culprits we see: connection pool exhaustion masquerading as 5xx (you'll see upstream_connect_time spike before the 500s), or retries amplifying a 10% failure into a 40% failure. Check your downstream_rq_retry counters — if retries are accounting for more than 25% of origin traffic during an incident, your retry budget is too generous and your breaker is opening too late. Log the breaker state transitions centrally; without that, you're flying blind when post-morteming a regional brownout.

Frequently Asked Questions

Should the circuit breaker live on the CDN edge or in front of the origin?

Both, but they serve different purposes. The edge breaker protects your PoPs and your users from waiting — it fails fast and serves stale or alternative content within 50-100ms. The origin-side breaker (often in your API gateway or service mesh) protects your origin infrastructure from being crushed by retry storms. We run breakers at both layers with tighter thresholds at the edge (50% failure over 10s) and looser ones at the origin (70% over 30s) to avoid double-tripping.

How is a circuit breaker different from a health check or outlier detection?

Think of them as a team. Health checks are proactive — they poll an endpoint every 5-10 seconds to decide if a node should get traffic. Outlier detection is passive — it ejects a single bad host based on its error rate. The circuit breaker is the big red switch for the entire upstream pool — it stops all traffic to that pool when the whole group is failing, not just one host. You need all three; relying on health checks alone means you'll keep hammering a failing pool for at least one check interval.

What should we return to the client when the circuit is open?

Never just return a raw 503 if you can avoid it. Your priority order should be: 1) Serve stale content with stale-if-error or stale-while-revalidate — users won't even notice for cacheable assets, 2) Fail over to a secondary origin or region if you have one, 3) Return a synthetic 503 with a Retry-After: 30 header and a minimal, cacheable error page. Anything else and you'll turn a 30-second origin blip into a user-visible outage that lasts for minutes due to client-side retries.

Can circuit breaking break session persistence or consistent hashing?

Absolutely, if you're not careful. When you eject a host that owns a hash ring partition, those keys will remap and you'll get a thundering herd on the next node. That's why we cap max_ejection_percent at 50% — we never eject more than half the ring at once. For stateful routes using IP hash, pair your breaker with a fallback that routes to a designated secondary rather than re-hashing arbitrarily, or you'll break affinity and dump user sessions.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker