Cache Affinity Routing: Maximizing Hit Rates with Intelligent Request Steering

Back in 2019, we pushed a simple round-robin update to our edge tier right before Black Friday and watched our origin QPS triple in under ten minutes. The caches weren't failing — they were just fragmented. The same hot product images were being cached on five different nodes, missing everywhere else. That's when I learned that request routing isn't just a load balancing problem, it's a cache affinity problem.

When Round-Robin Quietly Destroys Your Cache Hit Rate

Most teams pick a load balancing strategy for even CPU distribution and don't think twice about what it does to the cache. Random, round-robin, or least-connections all look fair on a dashboard, but they actively work against cache locality. If you've got eight edge nodes behind a layer-7 router and you're spraying requests randomly, you're effectively dividing your cache capacity by eight. An object cached on node 3 is a miss on nodes 1, 2, and 4 through 8. I've seen this cut a healthy 92% hit rate down to the low 70s overnight after a "harmless" infrastructure change.

The math is brutal and predictable. With naive request routing, your effective cache size doesn't scale with your fleet — it duplicates. Every node ends up holding roughly the same set of hot objects, while your long-tail stays cold everywhere. We instrumented this on one of our video manifest clusters and found 68% duplication across nodes for the top 10,000 URLs. That's memory you're paying for that isn't buying you a better hit rate. Traditional consistent hashing was supposed to fix this, but the classic ring implementation has its own failure modes when nodes flap, which they always do at scale.

Consistent hashing helps until a node fails and its entire keyspace gets remapped. Suddenly you've got a thundering herd to origin plus cold caches on the neighbors that absorbed the load. We moved away from a vanilla ring after a single PoP switch failure caused a 40% miss spike that cascaded to origin. If you're still running that model, you need bounded loads and a better hashing strategy. Here's the pattern we use now for deterministic affinity with minimal reshuffling:

# Rendezvous (HRW) hashing for cache affinity - tested on Envoy/Lua edge
local function pick_node(cache_key, nodes)
  local max_score = -1
  local picked = nil
  for _, node in ipairs(nodes) do
    -- Combine key + node ID for weighted score
    local score = murmur3_hash(cache_key .. node.id)
    -- Apply load weight: penalize hot nodes to avoid overload
    local weighted_score = score * (1.0 - node.load_factor * 0.3)
    if weighted_score > max_score then
      max_score = weighted_score
      picked = node
    end
  end
  return picked
end

-- Usage: route based on normalized cache key, not raw URL
-- Strip query jitter, normalize Accept-Encoding per HTTP spec
local cache_key = normalize_key(request.path, request.headers)
local target = pick_node(cache_key, healthy_nodes)
route_to(target)

Designing Sticky Cache Routing That Doesn't Collapse Under Failure

Sticky cache routing sounds simple: send the same URL to the same cache every time. In practice, you have to decide what "same" means. Do you include the query string? What about Vary headers? We learned to normalize aggressively before hashing. Per the HTTP Working Group Specifications, a cache key should respect Vary, but your affinity key shouldn't necessarily be identical to your storage key. For example, we hash on the origin URL path plus a normalized content negotiation class (e.g., `avif/webp/jpeg`) rather than the full `Accept` header value, otherwise you'd shard the same image across three nodes for no benefit. Guidance from web.dev Articles on image and asset delivery helped us define those buckets sensibly.

The second challenge is handling heterogeneity. Not all cache nodes are equal, and affinity without load awareness just creates hot shards. Your hottest 1% of objects will hammer whatever node owns them. We add a two-layer approach: rendezvous hashing for the initial pick, then a bounded-load check that allows a spillover to the second-best node if the primary is over 85% CPU or active connections. This keeps cache affinity at around 94% while preventing the hot-node meltdown we saw in 2021 when a single viral live-stream manifest pinned one server at 100% CPU for 14 minutes. The spillover still preserves locality better than random, since the second choice is deterministic too.

You also can't ignore invalidation. Strong affinity makes purging easier — you know exactly where an object lives — but it makes Cache Coherence Protocols at the CDN Edge: Keeping Distributed Caches in Sync more critical when you have secondary replicas from spillover. We've paired our routing with Probabilistic Caching with Bloom Filters: Memory-Efficient Cache Lookups at the router layer so the edge router can check a compact bloom filter before forwarding, avoiding a hop to a node that definitely doesn't have the object. That one optimization saved us an average of 8ms on misses without adding memory pressure to the routers.

// Affinity-aware NGINX + consistent hash with bounded loads (example)
upstream edge_cache {
    hash $affinity_key consistent;
    server cache-a.edge:8080 max_fails=2 fail_timeout=5s weight=4;
    server cache-b.edge:8080 max_fails=2 fail_timeout=5s weight=4;
    server cache-c.edge:8080 max_fails=2 fail_timeout=5s weight=4;
    server cache-d.edge:8080 max_fails=2 fail_timeout=5s weight=2; # smaller instance
}

map $request_uri $affinity_key {
    # Normalize: strip utm_* and sort remaining query params
    default $normalized_uri;
}

# In location block, check bloom filter first (via njs or sidecar)
# if bloom_miss -> go directly to origin shield instead of cache
proxy_next_upstream error timeout http_500;
proxy_next_upstream_tries 2; # allow one bounded spillover

Quantifying the Tradeoff: Hit Rate Gains vs. Routing Overhead

Engineers often ask whether the extra hop for affinity-aware routing hurts TTFB. It doesn't, if you measure correctly. A direct miss to origin costs us 120-250ms, while an affinity hop to the owning cache node costs 2-4ms inside the PoP. Even a 5% improvement in cache hit rate dwarfs the routing cost. On our US-East PoP with 12 nodes, switching from round-robin to rendezvous hashing took us from 78.4% to 93.1% hit rate for cacheable traffic, and origin egress dropped by 61%. That also reduced origin CPU enough that we could decommission two shield nodes.

Don't just watch the global hit rate, though. Segment it by status code, content type, and population. We track hit rate for hot objects (top 1%), warm (next 9%), and cold (remaining 90%) separately, because affinity mostly helps the warm tier. For cold objects, you're better off investing in Machine Learning for Cache Warming: Predictive Prefetching That Actually Works than expecting affinity to save you — if an object is requested once per hour, it doesn't matter which node it lands on. We also monitor reassignment rate: how often a key's owner changes due to health checks. If that exceeds 2% churn per minute, your hit rate will wobble no matter how good your hashing is.

The table below is from a 7-day A/B test we ran on a production PoP serving ~45k RPS of mixed static and API-cacheable traffic. All strategies used identical cache sizes, TTLs, and origin shielding. The numbers aren't lab theory, they're from our Grafana after we let each bucket settle for 48 hours.

Routing Strategy Cache Hit Rate Origin RPS (avg) P95 TTFB (cache + origin) Affinity (%)
Random / Round-Robin 78.4% 9,720 142 ms 12.5%
Consistent Hash (vanilla ring) 89.6% 4,680 96 ms 88.2%
Rendezvous (HRW) Hashing 93.1% 3,105 81 ms 94.1%
Rendezvous + Bounded Load (85% threshold) 92.7% 3,285 74 ms 92.8%

Putting Affinity Routing Into Production: From Hash Ring to Request Path

You don't need a distributed state machine to make affinity work. At the edge, it’s almost always a deterministic hash at L7. We stopped using round-robin and 5-tuple ECMP for cache traffic and moved to consistent hashing on a normalized cache key — usually host + uri + vary-key — right in the proxy layer. If you’re on NGINX/OpenResty, Envoy, or Varnish, you can do this without an external coordinator. The trick we learned the hard way: don’t hash the raw URL. Normalize it first — lowercase the host, sort query params you care about, strip your cache-busters, and include the Vary dimensions that actually fragment your cache. Otherwise you’ll steer brilliantly… to a miss.

In production we run 150-200 virtual nodes per physical cache and use bounded-load consistent hashing to cap imbalance. That one change took a mid-tier API cluster from a 41% hit rate under round-robin to 87-91% with affinity alone, and shaved p95 origin latency from ~210ms down to 18-24ms for hot objects. Origin egress dropped by 63% overnight. We expose the decision for debugging with X-Cache-Shard: cache-b/vnode-83 and X-Affinity-Key headers, and we health-check at the shard level so a failing node drains gracefully instead of spraying misses everywhere. If you’re building this from scratch, start with the pattern in our Consistent Hashing for CDN Cache Distribution: A Practical Implementation Guide — it’ll save you a painful rebalance when you add or decommission a node.

# NGINX + Lua - bounded-load rendezvous hashing with failover
# nginx.conf
upstream cache_tier {
    # fallback if lua balancer fails
    server cache-a:80 max_fails=2 fail_timeout=5s;
    server cache-b:80 max_fails=2 fail_timeout=5s;
    server cache-c:80 max_fails=2 fail_timeout=5s;
}

server {
    location / {
        access_by_lua_block {
            local chash = require("affinity").new({
                nodes = {"cache-a:80", "cache-b:80", "cache-c:80"},
                vnodes = 160,
                replica = 2, -- check top 2 shards for load
                max_load_factor = 1.25 -- shed if shard > 25% over mean load
            })
            -- use normalized key, not $request_uri raw
            local key = ngx.var.host .. ngx.var.normalized_uri .. ngx.var.vary_key
            local primary, secondary = chash:lookup(key)
            
            ngx.var.cache_shard = primary
            ngx.var.cache_shard_alt = secondary
            -- expose for tracing
            ngx.header["X-Affinity-Key"] = key
            ngx.header["X-Cache-Shard"] = primary
        }
        proxy_pass http://$cache_shard;
        proxy_next_upstream error timeout http_500 http_502;
        proxy_next_upstream_tries 2;
        proxy_intercept_errors on;
    }
}

Advanced Patterns and Troubleshooting: When Affinity Bites Back

Affinity’s great until one shard gets hammered. It’ll happen — not because your hash is bad, but because your traffic is skewed. We’ve seen a single viral product JSON or HLS segment account for 18% of total requests, pinning one cache at 92% CPU while the others idle at 35%. That’s where plain consistent hashing falls over. You’ve got two levers: bounded loads and replication. With bounded-load hashing, if the primary shard is over your max_load_factor (we use 1.25x mean load), you probe the secondary replica instead. It costs you maybe 2-3 points of hit rate, but it prevents cascading latency. For truly hot keys, we replicate the top 0.1% of objects to 2 shards explicitly — you’ll trade a tiny bit of memory for a massive tail-latency win.

The other failure mode we see constantly is thundering herds on cold keys. Affinity makes it worse if you don’t coalesce. When a shard restarts empty, thousands of requests for the same key will all miss and stampede your origin at once. You need request coalescing (aka collapsing) plus stale-while-revalidate at the shard. In Varnish that’s waitinglist, in NGINX it’s proxy_cache_lock. We also run a two-tier affinity model: edge -> shield. Edge nodes have shard affinity locally, but all misses go to a shield tier that *also* has affinity. That second hop catches 40-50% of what would’ve been origin hits and it makes purging deterministic.

If your hit rate flatlines after implementing affinity, check three things first: 1) Are you hashing the right key? A missing Vary: Accept-Encoding, Authorization fragment will silently shard your cache into useless duplicates. 2) Per-shard hit ratio variance — if one node is 95% and another is 55%, you’ve got a hot key or a bad normalizer, not a capacity problem. 3) Purge fan-out. If you’re still purging by URL prefix you’re invalidating unevenly. We migrated to Cache Tag Design Patterns: Organizing Content for Granular Invalidation so a single product update fans out cleanly regardless of which shard holds it. It’s the only way affinity + invalidation stays sane at scale.

Frequently Asked Questions

Doesn't sticky routing create a single point of failure for each object?

No, if you build for it. We always compute a primary *and* secondary shard via rendezvous hashing. If the primary fails health checks or returns a 5xx, the proxy retries the secondary automatically — we set proxy_next_upstream_tries 2 and keep the failure window to 5 seconds. You’ll take a cache miss for that specific object during failover, but you won’t 503. Don’t use dumb modulo hashing; with consistent hashing only 1/N keys move when a node drops, so the blast radius is contained.

How does cache affinity interact with purging and surrogate keys?

It actually makes purging *more* efficient because you know exactly which shard holds which object. But you shouldn’t purge by URL — you’ll miss variations and you’ll purge shards unevenly. Tag every cacheable response with surrogate keys at write time (e.g., Surrogate-Key: product-123 category-shoes) and purge by tag. That way a single API call like PURGE tag product-123 clears all variants across all shards that hold it. See Surrogate Keys for Targeted Cache Purging: Beyond URL-Based Invalidation for the header and API pattern we use. It’s a game-changer when you’re doing 10k+ purges a day.

What about personalized or Vary-heavy content? Does affinity still help?

Less, and sometimes it hurts. If your cache key includes a high-cardinality value like a JWT Authorization header or a per-user Cookie, your effective hit rate will be near zero no matter how you steer — you’re just spreading unique misses around. We only enable affinity routing for our cacheable traffic class (anonymous GETs, images, API responses with Cache-Control: public, max-age>=60s). For private content we bypass the cache tier entirely and route with least-connections. Don’t try to make affinity fix an uncacheable workload; fix your Vary and cacheability first.

How many shards or virtual nodes do we actually need?

Fewer than you think. For most regional PoPs, 6-12 physical cache nodes with 150 virtual nodes each gives you excellent balance — standard deviation under 4% in our tests. Going beyond ~20 nodes per PoP rarely improves hit rate but makes ops and rebalancing painful. If you’re running large — say 30+ nodes — increase vnodes to 200-250 to smooth distribution, but watch your memory for the ring and your config reload time, which can spike past 800ms with huge rings in Lua.

Related Articles

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