Last Black Friday, our P50 looked beautiful — 45ms globally, dashboards all green. Yet support tickets were exploding with "site feels slow" complaints. When we dug in, P99 was sitting at 1.8 seconds. A tiny fraction of users were getting hammered by slow origins, stuck TCP sessions, and that one overloaded PoP in São Paulo. That's the exact pain request hedging was built to fix, and after running it in production for three years, I wouldn't run a CDN edge without it.
Why Averages Lie and Tails Hurt
Most load balancing strategies are optimized for the median. You pick the closest PoP, the least-loaded origin, the lowest latency path, and 90% of the time it works great. But tail latency doesn't come from your typical request — it comes from the outliers: a garbage collection pause on an origin, a transient packet loss burst, a noisy neighbor on a shared host, or a cold cache path that has to go all the way back to S3. I've seen a single degraded SSD on an origin cluster push P99 from 120ms to over 800ms while P50 didn't budge a millisecond. If you're only watching averages, you don't even know you're bleeding users.
That's where request hedging flips the script. Instead of picking one server and praying it responds quickly, you send the same request to multiple backends in parallel and use whichever returns first. You're not waiting to detect a failure — you're racing them. Google published the pattern years ago in their tail latency work, and the HTTP Working Group Specifications touch on the underlying retry and idempotency concerns that make this safe for GETs. Unlike a naive retry that only fires after a timeout, a proper hedge strategy fires a second parallel request after a short, deliberate delay. You don't double your traffic for every request, you only hedge the slow ones.
We learned this the hard way back in 2019. We were using strict GSLB Implementation Patterns: Global Server Load Balancing for Multi-Region CDN with active health checks every 5 seconds. An origin in eu-west-1 started responding but very slowly — 2-3 seconds per request — so it never failed a health check. Our load balancer kept sending it 30% of traffic. Once we added hedging with a 30ms delay to a second origin in eu-central-1, that P99 collapsed. The slow origin was still in rotation, but users never waited for it.
// Hedged fetch with 30ms delay - Go-style pseudocode for an edge proxy
func hedgedFetch(ctx context.Context, req *Request, backends []Backend) (*Response, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
resultCh := make(chan *Response, len(backends))
// Primary attempt
go func() {
resp, err := backends[0].Do(ctx, req)
if err == nil {
resultCh <- resp
}
}()
// Hedged attempt after hedgeDelay - only fires if primary hasn't returned
time.AfterFunc(30*time.Millisecond, func() {
select {
case <-ctx.Done():
return // Primary already succeeded, don't hedge
default:
resp, err := backends[1].Do(ctx, req)
if err == nil {
resultCh <- resp
}
}
})
select {
case res := <-resultCh:
return res, nil // First response wins, context cancels the other
case <-ctx.Done():
return nil, ctx.Err()
}
}
Designing a Hedge Strategy That Doesn't Melt Your Origin
The biggest mistake I've seen teams make is hedging too aggressively. If you send every request to two origins with zero delay, you've just doubled your origin load for a 10-15% latency improvement. That's a great way to turn a small latency spike into a full-blown outage. The delay is everything. You want to wait just long enough that most fast requests already completed, so you only pay the duplicate cost on the slow tail. In our current edge stack, we use a dynamically tuned hedge delay based on the trailing P90 of that specific origin pool over the last 60 seconds, typically landing between 15ms and 50ms.
You've also got to be ruthless about what you hedge. Safe hedging is limited to idempotent, cacheable methods — GET and HEAD primarily. Never hedge a POST or anything with side effects unless you've got request coalescing and exactly-once semantics sorted out upstream. We also tie our hedging logic directly into our load balancing weights. When we're running Weighted Round-Robin for CDN: Proportional Traffic Distribution Across Heterogeneous Servers across heterogeneous origins (say, a mix of bare metal and autoscaled VMs), we don't hedge to a random peer. We hedge to the next highest-weight healthy peer that isn't the primary. That avoids hedging to a tiny VM that can't handle the burst anyway. And yes, you need hard ceilings — we cap hedged requests at 15% of total origin QPS per PoP; beyond that, we stop hedging and degrade gracefully.
Modern edge proxies make this easier than it used to be. Envoy, for instance, has built-in hedge policies, and you can get surprisingly far with a few lines of config and good observability. The key is tracking hedged vs. primary wins. If your hedge is winning more than 20% of races, your primary selection is broken — fix your load balancing before you tune hedging. The web.dev Articles on optimizing Time to First Byte show how much even 50ms at the edge matters for LCP, so this isn't micro-optimization — it's user-visible.
# Envoy retry/hedge policy for an origin cluster
# Only hedges GETs after 35ms, max 1 hedged attempt
route_config:
virtual_hosts:
- name: cdn_origin
routes:
- match: { prefix: "/" }
route:
cluster: origin_pool
hedge_policy:
hedge_on_per_try_timeout: true
per_try_timeout: 35ms
hedged_request_count: 1 # one parallel attempt max
retry_policy:
retry_on: "gateway-error,connect-failure"
num_retries: 0 # hedging replaces retries for tail latency
retriable_request_headers:
- name: ":method"
exact_match: "GET"
What Hedging Actually Costs in Production Numbers
There's no free lunch here — hedging trades compute and origin capacity for latency. The question is always whether that trade is worth it. In our measurements across three high-traffic properties (about 400k RPS at peak), a well-tuned 30ms hedge cut P99 by more than half while adding less than a fifth in extra origin requests. Go too low with the delay and your overhead explodes; go too high and you miss the window where hedging helps. The sweet spot moves with your origin's baseline latency, which is why static configs often disappoint.
I recommend starting conservative and measuring win rate. We log every hedged race: which backend won, by how much, and what the duplicate cost was. If you're using Least-Connections Load Balancing for CDN: Dynamic Distribution Based on Server Load at the L7 layer, hedging interacts with it in subtle ways — hedged requests inflate connection counts for a few milliseconds, which can make a healthy host look busier than it is. We had to exclude in-flight hedged requests from the least-connections calculation to avoid a feedback loop where hedging made the balancer avoid the fastest hosts.
Here's what we saw when we A/B tested different hedge delays on the same origin pool (mixed cache MISS traffic, 50/50 test, 24-hour window):
| Hedge Delay | P50 Latency | P95 Latency | P99 Latency | Extra Origin Requests |
|---|---|---|---|---|
| No hedging | 44ms | 210ms | 412ms | 0% |
| 15ms | 42ms | 118ms | 165ms | 38.2% |
| 30ms | 43ms | 112ms | 188ms | 18.7% |
| 50ms | 44ms | 145ms | 244ms | 7.1% |
| 80ms | 44ms | 182ms | 321ms | 2.4% |
Implementing Hedged Requests at the Edge: A Practical Blueprint
Most teams overcomplicate this. You don't need a custom data plane to get 80% of the benefit. If you're running Envoy, Nginx + Lua/OpenResty, or Varnish at the edge, you can implement a basic hedged request policy with a delayed timer and careful cancellation logic. The trick isn't firing the second request — it's knowing when not to, and killing the loser fast.
We've found the sweet spot is hedging only after your p95 latency for that origin pool. If your p95 to the origin is 45ms, don't hedge at 10ms or you'll just double your traffic for no reason. Set your hedge delay to p95 + a small buffer, say 50-60ms. And for the love of uptime, never hedge non-idempotent requests and never hedge when your origin pool is already hot. We gate hedging on two circuit breakers: origin CPU/load < 70% and active connection count < 80% of max. If either trips, hedging is disabled automatically and we fall back to standard retry. This is where solid Health Check Design Patterns for CDN: Shallow, Deep, and Synthetic Checks pay off — you need accurate, sub-second health state to avoid hedging into a dying node.
// Envoy-style hedged request logic (simplified Go/Lua pseudocode)
func handleRequest(req *Request, origins []Origin) *Response {
hedgeDelay := 55 * time.Millisecond // p95 (45ms) + 10ms buffer
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
// Primary attempt - use consistent hashing for cache affinity
primary := selectOrigin(req, origins) // e.g., consistent hash ring
resultCh := make(chan *Response, 2)
go func() {
resp, err := fetch(ctx, primary, req)
if err == nil { resultCh <- resp }
}()
// Hedge timer
select {
case res := <-resultCh:
return res // Primary won, no hedge needed
case <-time.After(hedgeDelay):
// Only hedge if origin pool is healthy
if !isPoolHot() && req.IsIdempotent() {
secondary := selectAlternateOrigin(req, origins, primary)
go func() {
resp, err := fetch(ctx, secondary, req)
if err == nil { resultCh <- resp }
}()
}
}
// First response wins, second is cancelled via context
res := <-resultCh
return res
}
The selectAlternateOrigin part matters more than you think. Don't just pick random. If you're using Consistent Hash Ring Architecture for CDN: Minimizing Redistribution on Server Changes for origin selection, your hedge should jump to the next node on the ring, not a random one. And if you're pinning users to origins for any reason, be aware that hedging breaks that affinity by design. For session-pinned workloads, stick to IP Hash for CDN Session Persistence: Consistent User Routing Without Cookies for your primary path and only hedge unpinned, cacheable GETs.
Advanced Patterns and When Hedging Goes Wrong
Once you've got basic delayed hedging working, you'll be tempted to get clever. Two patterns are actually worth it in production. First is adaptive hedge delay. Instead of a static 55ms, we track a rolling 5-minute p95 per origin pool per PoP and adjust the delay automatically. In our EU-West PoPs, p95 is 38ms during off-peak but spikes to 110ms at 8 PM — a static delay either hedges too aggressively or too late. Adaptive delay cut our wasted hedges by about 31%. Second is hedged cache fill. When you have a cache miss, hedging the origin fetch can shave 200-400ms off the p99 for the first user who misses, which is huge for cold content.
But here's where it goes wrong, and I've seen all three meltdowns. The first is the retry storm. You deploy hedging, tail latency drops from 900ms to 140ms, everyone celebrates — then a downstream dependency slows by 20ms and suddenly 40% of your requests are hedging, which triples origin QPS and causes a full outage. You must implement a hedge budget. We cap hedged requests at 15% of total requests per second per PoP. Once you hit that budget, no more hedges. Google's classic paper suggested 10%, we've found 10-15% is safe if your origins have headroom. The second failure mode is not cancelling the loser. If you don't propagate cancellation cleanly, that second request will run to completion, do a full DB query, and waste resources. Envoy does this well with hedge_on_per_try_timeout and auto-cancellation. If you're rolling your own in Nginx/Lua, you have to explicitly close the subrequest.
Finally, watch your metrics. Don't just look at p99 latency. Track hedge_attempt_rate, hedge_win_rate (how often the hedge actually beat the primary), and origin_overhead_multiplier. A healthy system has a win rate of 5-12% and an overhead of 1.03x to 1.08x. If your win rate is below 3%, your delay is too short or your origins aren't variable enough to benefit. If overhead creeps above 1.15x, you're hedging too much and need to tighten the budget. We dashboard these per PoP and per origin pool — global aggregates will lie to you.
Frequently Asked Questions
Should I hedge all requests or just cache misses?
Just cache misses and uncacheable dynamic GETs. Hedging a cache hit is pointless — you're adding latency and load to save nothing. We only enable hedging on the origin-fetch path after a miss. For cached content, your edge can serve it in 2-5ms anyway. Hedging hits just burns CPU and will actually increase your p50. Keep hedging strictly on the origin leg.
What's the difference between hedging and simple retries or timeouts?
Retries are sequential — you wait for a failure or timeout, then try again. That adds the full timeout to your tail. Hedging is parallel — you fire the second request before the first one fails, after a short delay. So if your timeout is 500ms, a retry makes your p99 at least 500ms + retry time. A hedge at 50ms keeps your p99 around 80-120ms even if the primary is slow. Use retries for hard failures, hedging for tail latency.
How do I hedge without breaking session persistence?
You mostly don't — hedging and strict session persistence are fundamentally at odds. If you must maintain persistence, don't hedge those routes. We use IP hash persistence only for cart/checkout and websocket upgrade paths and explicitly exclude them from hedging. For everything else (static assets, API reads, video manifests), we use consistent hashing which tolerates a hedge landing on a different origin since the data is shareable. Don't try to hedge a pinned session.
Will hedging hide real origin problems from my alerts?
Absolutely, if you let it. That's the most insidious risk. Your p99 will look fantastic while your origin p95 quietly degrades from 45ms to 150ms because hedges are masking it. You must alert on origin-side latency (time-to-first-byte from origin) and hedge win rate, not just client-facing latency. We alert if hedge win rate jumps above 20% for more than 5 minutes — that means the primary is systematically failing, not just tailing.