Cloudflare's 100TB Hash-Ring Fix: The Cost of 'Just Add More Hashes'

Sources

September 19, 2026. Cloudflare published a deep-dive on Friday that should be mandatory reading for anyone running a proxy fleet: Saving another 100TB of RAM with math (and Rust). An engineer filed a ticket that its internal load balancer — Pingora Backend Router (PBR), the service that routes cacheable requests to origin servers by URL — was using far more memory than expected. The root cause was the most boring possible suspect: the consistent-hash ring was too big. After a struct-layout fix and a 90% point-count reduction, PBR's memory footprint dropped by more than 100TB across the fleet, on top of the 100TB the DNS team freed in August by restructuring 1.1.1.1's cache entries.

Two aspects make this story worth a Platform Monkey teardown rather than a link drop. First, the failure mode is universal: the 160-points-per-weight-unit default that Cloudflare, nginx, and half the ecosystem inherited from the original ketama implementation is not a load-balancing number — it is a memory number, and almost nobody measures it. Second, the fix shipped as an upstreamable open-source change in the pingora-ketama crate (a v2 ring behind the unadvertised v2 cargo feature, available in 0.9.0), which means the remedy for "my consistent-hash ring is secretly a 6GB allocation" is now a dependency bump away for anyone running Pingora-based proxies.

What follows is the operator's teardown: the math that justifies how few hashes you actually need, the Rust struct-layout traps that made the first fix work, the dual-ring migration that avoided a global cache invalidation, and the numbers you should plug into your own Envoy, HAProxy, or Pingora configs before your next capacity review. If your takeaway is anything other than "I should go count the points on my rings," the article has failed.

The Setup: Where the Memory Actually Went

PBR routes cacheable requests to cache servers using consistent hashing on the URL. The property that makes consistent hashing useful for caches — add or remove a server and only ~1/N of keys remap — is bought by hashing each server onto the ring many times. In the ketama lineage that nginx inherited and pingora-ketama ports, the number of points per server is weight × 160, where weight is proportional to the server's disk capacity. The constant is literally named in the source:

/// This constant is copied from nginx. It will create 160 points per weight
/// unit. For example, a weight of 2 will create 320 points on the ring.
pub const DEFAULT_POINT_MULTIPLE: u32 = 160;

Then the multiplier you didn't choose: PBR runs not one ring but dozens, because cache features and compliance constraints mean different request classes can only land on server subsets. Feature combinations explode into 2handful rings, each paying full point tax. The post's numbers: weight factor ~625 per server (disk-proportional), so k = 160 × 625 = 100,000 points per server, across ~2,048 servers per data center, across dozens of rings, across every data center on Earth. The ticket that started it all: 6GB of pure hash points in a single PBR replica.

Where PBR's point memory comes from — every layer multiplies:

  servers per DC ............ ~2,048     ─┐
  weight per server (disk) ... 625         │ ──▶ k = 160 × 625
  DEFAULT_POINT_MULTIPLE ..... 160       ─┘      = 100,000 points/server
                                                    × 2,048 servers
  ┌─────────────────────────────────────┐         = 204.8M points/ring
  │  ONE ring = ~204,800,000 points      │
  │  at 8 bytes/point (PointV1)          │         = ~1.6 GB / ring
  └─────────────────────────────────────┘
  feature-subset rings ........ dozens   ──▶  ~6 GB of points / replica
  data centers ............... global   ──▶  100+ TB of fleet RAM

Scale hides waste better than anything else in systems engineering. A 6GB allocation is invisible per node and completely ordinary in a fleet profile — until the day someone multiplies it by the fleet. The Performance team's ticket-to-100TB arc is the reminder that "ordinary" and "justified" are different words: the 160× multiplier survived years of review because nobody had derived what it was buying.

The Math: How Many Hashes Does a Ring Actually Need?

Here is the actual decision-driving content of the post, and it's better than most vendor deep-dives because the authors derived the exact standard deviation for k hashes per server rather than quoting an approximation:

Exp(k) = 1/N                                    — expected share per server
SD(k)  = (1/N) · sqrt( (N−1) / (k·N+1) )       — exact for k hashes/server
CV(k)  = SD(k)/Exp(k) = sqrt( (N−1) / (N·k+1) ) — imbalance as fraction of fair share

(At k=1 this reduces to the classic result SD = (1/N)·sqrt((N−1)/(N+1)).)

The coefficient of variation (CV) is the number to internalize: it's the expected load imbalance across your servers as a fraction of fair share. We recomputed the post's table from the published formula so you can see the shape of the curve without opening a spreadsheet:

Hashes per server (k)CV at N=100 serversMeaning (worst-server load vs. fair share)
199.0%Some servers do ~2× the work; others nearly idle
1031.5%Still embarrassing for any tier-1 service
10010.0%Visible hot spots in request-rate dashboards
1607.9%The nginx/ketama default everyone ships
1,0003.2%Fine for most purposes
10,0001.0%Overkill for cache-tier load balancing
100,0000.31%Cloudflare's old point count — the last 90,000 hashes bought 0.7 percentage points of CV

Read the last two rows carefully, because this is the post's core insight: CV falls off as 1/√k, so every order-of-magnitude in point count buys only a ~3× reduction in imbalance. Going from 10,000 points to 100,000 points — a 10× memory cost — improved the error margin by 0.7 percentage points. The last 90% of the memory was buying nothing.

And then the collision tax: the post's math assumes a continuous ring, but real rings hash to 32-bit integers. At Cloudflare's scale, collisions arrive via the birthday paradox: with N·k total points, expected colliding pairs ≈ (N·k)²/233. We computed what that means at Cloudflare's scale:

k per serverTotal points (2,048 servers)Expected colliding pairs (32-bit space)Effect
1,6003.28M~1,250Negligible — collisions statistically invisible
10,00020.5M~48,800Measurable but harmless; a rounding error in imbalance terms
100,000204.8M~4.9MMillions of silently dropped point contributions — error creeps back in as you scale the ring

This is the part of the story that should make a platform engineer sit up. The conventional wisdom — "more points, more accurate" — isn't just diminishing returns, it's negative returns past a threshold: at 204.8M points in a 232 hash space you expect ~4.9M colliding pairs, and each collision silently drops a point's contribution to its server's range. You pay 10× the memory to make accuracy worse than the k=10,000 ring would deliver. The remedy Cloudflare chose: cut the point count by 90% (k: 100,000 → 10,000), keeping CV ≈ 1% while shrinking every ring by an order of magnitude.

The Fix, Part 1: Rust Struct Layout Is a Capacity Decision

The first fix predates the point-count reduction and doesn't require any math. The old point representation in pingora-ketama is:

struct PointV1 {
    node: u32,
    hash: u32,
}

Four bytes of hash, four bytes of node index. The hash is irreducible — it's a 32-bit CRC-based value, that's the algorithm — but the node index is grotesque overkill: PBR will never coordinate 232 cache servers. A u16 indexes 65,536 servers; the post says 216 ≈ 65k is a comfortable ceiling. The obvious change —

struct PointV2 {
    node: u16,
    hash: u32,
}

is a no-op in Rust, and the post deserves credit for explaining why instead of skipping it: Rust aligns struct fields, and the size of a struct must be a multiple of its most-aligned field. With a u32 present, PointV2 still occupies 8 bytes — two bytes are simply wasted on padding. You cannot shrink a struct below its alignment by reordering fields. The working trick is to store the point as a raw byte array and decode fields through getters, so the compiler can't insert padding:

#[cfg(feature = "v2")]
struct PointV2([u8; 6]);

#[cfg(feature = "v2")]
impl PointV2 {
    fn new(node: u16, hash: u32) -> Self { ... }

    fn hash(&self) -> u32 {
        u32::from_ne_bytes(self.0[0..4].try_into().expect("There are exactly 4 bytes"))
    }

    fn node(&self) -> u16 {
        u16::from_ne_bytes(self.0[4..6].try_into().expect("There are exactly 2 bytes"))
    }
}

Six bytes per point instead of eight: an immediate 25% reduction in point memory with zero algorithmic change. Two engineering notes for anyone porting the idea:

The Fix, Part 2: A v2 Ring That Can Run Beside the v1 Ring

The deeper fix required a new ring type, because point count is no longer a constant — it's a parameter. The crate models this as a versioned ring, with the v2 point count passed explicitly:

#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
pub enum Version {
    #[default]
    V1,
    #[cfg(feature = "v2")]
    V2 { point_multiple: u32 },
}

Version::V2 { point_multiple } takes the point multiplier as a field, so a caller can build a 160× ring, a 16× ring, or whatever its math says — the library stops hardcoding the decision. The construction path multiplies each bucket's weight by the version's point_multiple, and the sort changes too: v2 rings use i_key_sort's OneKeyAndCmpSort (the dependency the v2 cargo feature pulls in) for a faster single-key sort with a secondary comparison for collision dedup, replacing v1's generic sort_unstable.

The public API is otherwise unchanged, which is the entire point of the design. Callers build the same Bucket list and get the same Continuum; choosing v2 is one enum variant:

use pingora_ketama::{Bucket, Continuum, Version};

// v1 (default): 160 points per weight unit — identical to what the crate
// has always shipped, byte-for-byte compatible with nginx/memcached ketama.
let ring_v1 = Continuum::new(&buckets);

// v2: compact 6-byte points + your own point multiplier.
// point_multiple: 16 = the ~90% reduction Cloudflare used (160 → 16).
let ring_v2 = Continuum::new_with_version(
    &buckets,
    Version::V2 { point_multiple: 16 },
);

Two details in the public API are worth flagging before you adopt:

The Migration: How Not to Invalidate Every Cache on Earth

Here is the operationally hardest part of the story, and the one most teams would get wrong. Changing the hash ring changes which backend server receives each cacheable URL. Flip the ring globally and you effectively invalidate almost every cached object on Earth at once — a memory optimization becomes an apocalyptic origin-traffic incident. The post describes a migration design that is textbook for any ring/hash/topology change:

PBR's dual-ring migration — decouple which ring from where it's used:

                    ┌──────────────────────────────────────────┐
                    │              PBR replica                │
  request ────────▶ │                                          │
                    │  migration framework (per-request):      │
                    │    request hash ─▶ stable ring choice    │
                    │                                          │
                    │   ┌──────────────────┐  ┌─────────────┐ │      ┌────────────┐
                    │   │ OLD ketama ring  │  │ NEW v2 ring │ ──────▶ │ cache      │
                    │   │ (v1, 100k pts)   │  │ (16k pts)   │  …────▶ │ servers    │
                    │   └──────────────────┘  └─────────────┘ │      └────────────┘
                    └──────────────────────────────────────────┘
  Rollout: small validation DCs ─▶ progressively larger DC groups ─▶ global
  Controls: (1) % of traffic on new ring  (2) WHICH DCs move — independent axes
  Rollback: send requests back through the old ring, no redeploy needed
  Signals: backend-selection traces, ring-version counters, PBR connection
           errors, process memory, startup time, cache behavior, origin traffic

Four properties make this migration the pattern to copy, and none of them are about consistent hashing:

  1. Both rings live in memory during transition. Cost: during the migration, PBR temporarily carried both the old 100k-point rings and the new compact ones — the memory win is realized only at the end, not during the rollout. This is the correct trade: paying peak memory temporarily to avoid any hard cutover.
  2. Stable per-request ring selection. The migration framework decides ring choice by request hash, so a given URL consistently uses one ring during transition — no flip-flopping cache entries, no thundering re-hash.
  3. Two independent rollout axes. Traffic percentage and data-center scope are controlled separately. A plain "10% → 50% → 100% of requests globally" would have spread cache churn everywhere simultaneously; DC-scoped rollout kept the blast radius small enough to attribute cause. If you learn one operational lesson from this post, make it this one — percentage rollouts are the wrong tool for anything that changes data placement.
  4. Rollback without redeploy. Because both rings coexisted behind the framework, reversal was a routing decision, not a build-and-ship cycle. The post notes the old-ring path was removed only after the migration hit 100%.

The payoff chart in the post shows PBR's memory the week of the change minus a few weeks prior: a sharp drop on the day the old-ring code path was decommissioned. The difference: 100TB. Not by buying fewer servers — by deleting a constant nobody had re-derived in a decade.

What This Looks Like at Your Scale

Cloudflare numbers can be alienating — petabytes of RAM make 6GB seem like rounding error. So here is the same arithmetic at scales platform teams actually operate. The formula: ring bytes ≈ servers × weight × point_multiple × bytes_per_point (then multiply by however many feature-subset rings you run — the multiplier almost everyone forgets):

Fleet shapePoints (160× default)v1 ring size (8 B/pt)v2-style ring (6 B/pt, 16× multiple)Saving
50 servers, weight 1 (a typical HAProxy/nginx tier)8,00064 KB4.8 KB~92%
200 servers, weight 10320,0002.6 MB192 KB~92%
2,048 servers, weight 625 (one Cloudflare DC)204,800,0001.6 GB123 MB~92%
That, × dozens of feature rings, × global DCs~1011+ points100+ TBmeasured 100TB dropthe blog post

The top rows make the point that this is not a Cloudflare-only problem: at small scale the waste is kilobytes and irrelevant. The pattern turns into real money at the middle row — hundreds of MB per proxy process, multiplied by replica count — and fleet-defining at the bottom. And because CV falls as 1/√k, accuracy is a real trade: at N=200, k=160 gives CV 7.9% while k=16 gives CV 24.9%. If ~25% worst-server imbalance is unacceptable for your tier — and for a cache tier it usually is — then 16× is the wrong multiple and the honest answer is somewhere between: pick k from your imbalance SLO, not from a 2010-vintage constant. That is the entire argument of the v2 design: point count is now a parameter, not a legacy constant.

One more trap the story quietly illustrates: weights multiply the problem. Cloudflare's 625× weight factor pushed per-server point counts to 100k. If your load balancer takes per-server weights from capacity data (disk GB, CPU count), your ring size is coupled to your hardware refresh cycle — every storage-dense generation silently grows the ring. A 4TB-node fleet migrating to 16TB nodes quadruples point memory without anyone touching a config file. Ring size belongs in capacity reviews, not in "it came with the defaults."

The Ecosystem Context: You Are Probably Shipping 160× Too

Cloudflare is not an outlier; the 160 default is everywhere, and few operators can state what it costs them:

Proxy / load balancerConsistent-hash flavorPoints / table sizeMemory behavior & knobs
nginx (ketama heritage)Consistent hash (upstream)160 per weight unitSame DEFAULT_POINT_MULTIPLE lineage pingora-ketama ports; memory scales with total weight; no per-ring size knob
pingora-ketama 0.9.0nginx-compatible ketama160 (v1) / configurable (v2)v2 feature: 6-byte points + Version::V2 { point_multiple } — the fix described in this article, shipped upstream
Envoy ring_hashRendezvous-family ring hashmin 1024 entries, max 8M (default 1024)minimum_ring_size is a floor, not a target: Envoy grows the ring (and multiplies points per host) to approximate weights; large fleets can land far above the floor
Envoy MaglevMaglev hashingFixed 65537-entry tableFlat memory profile (one table), better disturbance properties than rings; cannot weight by arbitrary ratios as precisely
HAProxy map-based hashMap-based CHB (default mapsize 50,079)50,079 slotsTable size is the memory; weights interpolate into slots; hash-balance-factor tunes smoothness

HAProxy numbers for the table: the default hash-map is 50,079 slots and the docs note it stays small enough to fit in cache lines. Envoy ring_hash defaults are from current Envoy docs (min 1024 / max 8M). The takeaway across all rows: every implementation ships a hash-density default tuned for the 2010s, and none of them documents the memory cost in capacity terms. If you run a large Envoy fleet with weighted hosts, do the arithmetic on minimum_ring_size before your next capacity review — same exercise, different constant.

The Skeptic's Checklist: What to Verify Before Crediting the Story

We verified the load-bearing claims against the shipped source; here's the receipt trail so you can re-run it:

What we could NOT independently verify: per-DC ring counts, exact fleet RAM totals, and the migration timeline — internal details only the post attests. That's normal for vendor engineering blogs; the difference is this one shipped its fix in a public crate, which moves it from "trust me" to "cargo add and inspect."

Who Should Care, and Who Should Skip

Care if: you operate a proxy or load-balancing tier where consistent hashing meets weighted servers — CDN/edge caches, object-cache routers, distributed rate-limit sharders, session-affinity tiers. Care doubly if weights derive from hardware capacity (disk/CPU), because your ring grows with every hardware generation. Care triply if you run multiple feature- or compliance-subset rings; the combinatorial multiplier is where single-digit-MB-per-ring becomes GB-per-process.

Skip if: your load-balancing tier is small (dozens of servers, uniform weights) — your ring is measured in kilobytes and this entire conversation is a rounding error. Skip if your "consistent hashing" is actually Envoy Maglev or HAProxy map-based, whose flat table sizes already bound memory. Skip if you have no per-request class constraints forcing multiple rings — one modest ring is fine at 160×.

The platform-engineering takeaway isn't "copy Cloudflare." It's that the most expensive line of code in your infrastructure is probably a default constant that predates your employment, was tuned for someone else's fleet, and has never been re-derived against your SLOs. Cloudflare's Performance team turned a ticket into 100TB by doing the arithmetic nobody had done since the constant was inherited. Your fleet has the same kind of constants. Go find yours — and when you do, the pingora-ketama v2 ring is a working reference implementation of what "fixed" looks like: smaller points, fewer of them, and the count finally, deliberately chosen.