Varying Edge Cache by Cookie Without Exploding Cardinality

This guide is part of Cache Key Normalization and the Vary Header at the Edge. It solves one concrete task: you need a cached page to differ by something stored in a cookie — an A/B test bucket, or a locale — without giving every visitor their own cache entry.

The problem

You run an A/B test. Variant A and variant B of the homepage are both cacheable, and which one a visitor sees is decided by a cookie. The naive approach is to vary the cache on the Cookie header. The instant you do, your hit ratio collapses toward zero — because the Cookie header is one of the highest-cardinality values in the request. It carries a session id, a CSRF token, analytics ids, consent state, and your A/B bucket, all concatenated. Varying on the whole header means every visitor, and often every session, gets a unique cache key. You have effectively disabled the cache.

You only need to split the cache two ways (A or B), or for locale, a handful of ways. The fix is to derive a tiny, bounded value from the cookie and vary on that, never on the raw header.

Root cause: cache cardinality is multiplicative

Each axis you vary on multiplies the number of cache entries for a URL. Vary on nothing: one entry. Vary on a 2-way bucket: two entries. Vary on the raw Cookie header, whose value is effectively unique per user: as many entries as users. Since cache storage and hit ratio both degrade with cardinality, the rule is absolute — vary only on low-cardinality, derived values. At the edge you read the cookie off the Headers object, extract the one field you care about, collapse it to a small set, and fold that token into the cache key. The raw cookie never touches the key.

Deriving a low-cardinality cache axis from a cookie A high-cardinality cookie header is parsed to extract a bucket cookie, collapsed to two values, and used to split the cache into two entries. Cookie: sid=9f3..; ab=B; _ga=GA1..; consent=1 cardinality ≈ users extract "ab" collapse → A|B key …|ab=A key …|ab=B
Extract one field, collapse it to a small set, and split the cache only that many ways.

Do not parse the whole cookie jar into an object if you only need one value — read just the field you care about. A non-backtracking parser avoids the catastrophic-regex risk on attacker-supplied cookie headers.

function readCookie(request: Request, name: string): string | null {
  const header = request.headers.get("Cookie");
  if (!header) return null;
  for (const part of header.split(";")) {
    const eq = part.indexOf("=");
    if (eq === -1) continue;
    const k = part.slice(0, eq).trim();
    if (k === name) return decodeURIComponent(part.slice(eq + 1).trim());
  }
  return null;
}

Step 2: Collapse the value to a bounded set

The raw cookie value may be richer than you need. An A/B cookie might store B:exp-47:2026-06-01; you only want B. A locale cookie might store any of 200 BCP-47 tags, but you serve 4 languages. Map the raw value through an explicit allowlist with a default, guaranteeing the output is one of a fixed, small set:

function abBucket(request: Request): "A" | "B" {
  const raw = readCookie(request, "ab");
  // Only the first token matters; anything unexpected falls back to control.
  return raw?.split(":")[0] === "B" ? "B" : "A";
}

const SUPPORTED_LOCALES = ["en", "de", "fr", "ja"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];

function localeBucket(request: Request): Locale {
  const raw = (readCookie(request, "locale") ?? "").slice(0, 2).toLowerCase();
  return (SUPPORTED_LOCALES as readonly string[]).includes(raw)
    ? (raw as Locale)
    : "en";
}

The allowlist-with-default is the load-bearing part: it caps cardinality regardless of what the cookie actually contains. A malformed or malicious locale=zz-ZZ-garbage collapses to en, never creating a new cache entry.

Step 3: Fold the bucket into the cache key

Append the derived token to the canonical key. Because the token is one of two (or four) values, the cache splits exactly that many ways.

interface CookieVaryConfig {
  cookieName: string;
  bucket: (request: Request) => string; // returns a low-cardinality token
}

function cacheKeyWithCookie(
  request: Request,
  baseKey: string,
  config: CookieVaryConfig,
): string {
  const token = config.bucket(request);
  return `${baseKey}|${config.cookieName}=${token}`;
}

Two header rules keep downstream caches honest. First, you generally should not emit Vary: Cookie — it tells shared caches to vary on the whole high-cardinality header, re-introducing the explosion you just avoided. Instead, vary only in your own key and keep the response Cache-Control: public so the platform caches it. Second, when the edge assigns a bucket to a first-time visitor, set the cookie on the response and make sure that first response is not cached under a bucket the client does not yet have:

function assignBucketIfMissing(request: Request, response: Response): Response {
  if (readCookie(request, "ab")) return response; // already bucketed
  const assigned = Math.random() < 0.5 ? "A" : "B";
  const res = new Response(response.body, response);
  res.headers.append(
    "Set-Cookie",
    `ab=${assigned}; Path=/; Max-Age=2592000; SameSite=Lax; Secure`,
  );
  // Do not cache the bucket-assigning response; it is per-visitor.
  res.headers.set("Cache-Control", "private, no-store");
  return res;
}

Configuration snippet (Cloudflare Worker)

export default {
  async fetch(request: Request, _env: unknown, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const baseKey = `${url.origin}${url.pathname}`;
    const key = cacheKeyWithCookie(request, baseKey, {
      cookieName: "ab",
      bucket: abBucket,
    });

    const cache = caches.default;
    const cacheReq = new Request(`https://cache.internal/${encodeURIComponent(key)}`);
    const hit = await cache.match(cacheReq);
    if (hit) return hit;

    const fresh = await fetch(request);
    if (fresh.ok) ctx.waitUntil(cache.put(cacheReq, fresh.clone()));
    return fresh;
  },
};

Local vs production divergence

Behavior Local dev Production edge
Bucket assignment Math.random reseeds per restart; sticky only within a session Sticky via the persisted cookie across PoPs
Cache split No shared cache; both buckets just miss Exactly N entries per URL where N = bucket count
Missing/garbage cookie Rare in manual tests Common; must collapse to default deterministically
Secure cookie attribute May be dropped over http://localhost Enforced over HTTPS

Vitest validation

import { describe, expect, it } from "vitest";
import { abBucket, localeBucket, cacheKeyWithCookie } from "./cookieVary";

function reqWithCookie(value: string): Request {
  return new Request("https://x.com/", { headers: { Cookie: value } });
}

describe("cookie-derived cache axis", () => {
  it("collapses any ab variant token to A or B", () => {
    expect(abBucket(reqWithCookie("ab=B:exp-47"))).toBe("B");
    expect(abBucket(reqWithCookie("ab=A"))).toBe("A");
    expect(abBucket(reqWithCookie("sid=9f3"))).toBe("A"); // missing -> control
  });

  it("caps locale cardinality with a default", () => {
    expect(localeBucket(reqWithCookie("locale=de-DE"))).toBe("de");
    expect(localeBucket(reqWithCookie("locale=zz-garbage"))).toBe("en");
    expect(localeBucket(reqWithCookie(""))).toBe("en");
  });

  it("produces only as many keys as buckets", () => {
    const base = "https://x.com/home";
    const keys = new Set([
      cacheKeyWithCookie(reqWithCookie("ab=A"), base, { cookieName: "ab", bucket: abBucket }),
      cacheKeyWithCookie(reqWithCookie("ab=B"), base, { cookieName: "ab", bucket: abBucket }),
      cacheKeyWithCookie(reqWithCookie("ab=B:exp-9"), base, { cookieName: "ab", bucket: abBucket }),
      cacheKeyWithCookie(reqWithCookie("sid=zzz"), base, { cookieName: "ab", bucket: abBucket }),
    ]);
    expect(keys.size).toBe(2); // only A and B, never per-user
  });
});

Pitfalls

  • Varying on the raw Cookie header. Highest-cardinality value in the request; doing so disables the cache. Always derive a bounded token.
  • No default in the bucket function. An unrecognized cookie value that passes through untouched creates a fresh cache entry. Always map through an allowlist with a default.
  • Emitting Vary: Cookie. Pushes the cardinality explosion onto downstream shared caches and the browser. Vary in your own key instead.
  • Caching the bucket-assignment response. The first response that sets the cookie is per-visitor; mark it private, no-store so a random bucket is not cached for everyone.
  • Including expiry or experiment metadata in the token. B:2026-06-01 is higher cardinality than B. Strip to the decision value before folding it in.

Production deployment checklist

  • The cache varies on a derived token, never the raw Cookie
  • No Vary: Cookie
  • Bucket-assignment responses are
  • Assigned cookies carry Secure, SameSite, and a sane

Frequently Asked Questions

Why not just set Vary: Cookie?

Because Vary: Cookie tells every downstream cache to key on the entire Cookie header, which is effectively unique per user. That recreates the cardinality explosion you are trying to avoid. Instead, derive a low-cardinality token, fold it into your own key, and keep the response publicly cacheable.

How many cache entries will varying by cookie create?

Exactly as many as your bucket function can return. A two-way A/B test creates two entries per URL; a four-locale split creates four. The allowlist-with-default in the bucket function guarantees this cap regardless of what the raw cookie contains.

How do I assign a bucket to a first-time visitor without poisoning the cache?

Assign the bucket on the response, set the cookie, and mark that specific response private and no-store. It is a per-visitor response, so it must not be cached for everyone. Subsequent requests carry the cookie and hit the correctly bucketed cache entry.

Can I vary on a locale cookie the same way?

Yes. Read the locale cookie, slice it to the language subtag, and map it through your supported-locales allowlist with a default such as en. Any unsupported or malformed value collapses to the default and never creates a new cache entry.