5 min readPower features

Race the tail

Stop letting a few slow requests ruin your p99. Learn how hedging sends a duplicate request to "race the tail" and cut latency spikes.

hedginglatency

TL;DR: Tail latency - the slowest 1% of your calls - is often caused by a single "zombie" request. Hedging fixes this by starting a second copy of the call if the first one takes longer than almost every other call. By taking whichever answer arrives first, you shorten your p99 for the cost of a small, bounded increase in load.

The Tyranny of the Tail

If you look at a latency histogram, most of your calls are fast. But then there's the tail: those few requests that take 10x the normal time.

In a microservices architecture, the tail is a contagion. If your frontend calls five services, and each has a p99 of 1 second, your frontend's p99 is effectively the sum of those tails. One slow request at the bottom of the stack can make the entire user experience feel broken.

The traditional answer is to set a timeout. But if you time out at 1 second, you just turn a slow success into a failure. You've solved the latency, but you've broken the call.

Enter Hedging: Racing the Tail

Hedging is the a-ha moment for tail latency. Instead of waiting for a request to fail, you simply start another one.

If a call is taking longer than almost every other call to that dependency, it's probably stuck. By starting a second attempt now, you get two draws from the latency distribution. The chance that both draws land in the 99th percentile is 1 in 10,000. You've effectively traded a tiny bit of extra load for a massive reduction in p99.

The Safety Problem: Why Quantiles Matter

The biggest fear with hedging is the "Load Spike." If you set a fixed hedge delay of 100ms, and your dependency suddenly slows down so that every call takes 110ms, you've just doubled your traffic to a service that is already struggling. You've turned a brownout into an outage.

NResilience solves this by making the hedge threshold a live quantile.

When you configure Hedge.At(0.95), the library tracks the recent p95 latency. The second request only fires if the first one exceeds that p95.

Because the quantile moves with the traffic, the fraction of calls that hedge stays constant (roughly 5%) regardless of whether the dependency is responding in 10ms or 10 seconds. The load on the dependency is stable, and the protection is constant.

The Hedging Gates

To ensure hedging only happens when it's safe and useful, NResilience applies several guards:

  1. The Baseline Gate: Hedging is disabled until the latency estimate has enough samples (MinimumSamples, default 20) to be reliable. A cold policy hedges nothing.
  2. The Concurrency Gate: MaximumConcurrent bounds how many attempts may be in flight at once, counting the first (default 2). This prevents a single call from spawning an army of duplicates.
  3. The Health Gate (SuppressAt): Hedging stops when the dependency's error rate reaches a fraction of the breaker's trip point (half, by default). Sending extra load to a failing service is a mistake.
  4. The Value Gate (WinRate): Opt-in. If the second request almost never wins the race, the policy retreats and hedges less; when hedges start winning again, it adds hedging back.
  5. The Criticality Gate: Opt-in, through UseAmbientCriticality. A call running at Criticality.Sheddable is never hedged, whatever the estimate says. Hedging spends capacity to buy latency, and there is no latency worth buying when nobody is waiting for the answer. See Deadlines that travel for how a level reaches the policy in the first place.
var policy = Resilience.Http with
{
    Attempts = 3,
    Hedge = Hedge.At(quantile: 0.95) with
    {
        MaximumConcurrent = 2,
        SuppressAt = 0.5 // stop hedging halfway to a breaker trip (the default)
    }
};

Watching the Threshold

Because the threshold moves, it is a number worth watching rather than a number you set once. Read it straight off the policy:

var armsAt = policy.Measured.HedgeThreshold;   // null until the estimate has MinimumSamples

That is the latency at which the library starts duplicating load, so it belongs on the same dashboard as the extra traffic it explains. In NResilience.Extensions the same figure arrives as nresilience.hedge.threshold, recorded each time a hedge fires; the property is the one that answers before a hedge has fired.

One caveat: the gates above are asked when the threshold fires, not when you read it. A non-null reading is the point at which a hedge would be considered, not a promise that one starts.

A second caveat about what the quantile is measuring. It is wall clock around your callback, so a thread pool deep enough to add 400ms to every call raises the threshold too, and hedges stop firing during exactly the incident where a second copy of the work is the wrong answer anyway. Setting Saturation = Saturation.Above(5) stops the estimate learning that - see Measured, not guessed for the mechanism.

Cost Accounting: When to Hedge

Hedging isn't free. You are spending extra CPU, memory, and network bandwidth. It's a trade-off: is a 5% increase in load worth a 90% reduction in p99?

For most "read" operations (GET requests), the answer is yes. For "write" operations (POST/PUT), you must ensure the operation is idempotent - meaning the dependency can handle the same request twice without side effects. NResilience handles this via the MarkRepeatable() flag, ensuring only safe calls are hedged.

Summary

Hedging is the most effective tool for killing the tail. By racing a duplicate request against a slow one, you route around transient glitches and zombie requests. Because NResilience uses live quantiles instead of constants, you get the benefits of a shorter tail without the risk of a retry storm.

Hedging works well for single-value calls, but streams have different constraints. Streams that fail mid-flight explains why protection is limited to the time-to-first-element and what that means for IAsyncEnumerable.