Networking
Part 2 of 6 · CDN & edge cacheCache Keys & Vary
The cache key is the identity of a variant. Cookie in Vary or raw query strings explode cardinality; normalize host/path and put only true content axes in the key.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Overview
The hierarchy hub places the object. This page names it. Two requests share a cached body only when they hash to the same key. If the key is too fat, hit ratio dies and the origin sees almost every request. If the key is too thin, Alice gets Bob's cart.
A senior answer is not "the key is the URL." It is: scheme + host + canonical path + the query params that change bytes + an allow-listed Vary set. Everything else is either dropped at the edge or never forwarded.
Why a tight key wins
| Approach | What goes in the key | Hit ratio | Failure mode |
|---|---|---|---|
Naive URL + full query + Vary: Cookie | Every tracker and every session | Near zero | Origin DDoS; possible cross-user leak if you store private pages |
Raw Vary: Accept-Language, User-Agent | Every browser string and locale tag | Tiny | Combinatorial explosion of identical HTML |
| Winner: normalize + allow-list | Host, path, id/v/page, negotiated encoding, language bucket | High | You must still send Vary for every axis you actually honor |
- 1
one product URL → one object per user and per ad click
Fat key: Cookie + utm_* + unsorted query
/p?id=42&utm_source=adand/p?utm_source=x&id=42miss each other. A session cookie makes every shopper a unique object. The CDN becomes a very expensive reverse proxy. - 2
Winner: canonical URL + allow-listed Vary
Lower-case host, collapse slashes, drop tracking params, sort the rest, fold
en-US/en-GBtoen. Static paths strip cookies before lookup. Personalized bits live on a different host or a stable A/B cookie you actually whitelist. - ?
When Vary is the wrong tool
If only a header fragment differs, split URLs (
/img.webpvs/img.jpg) or ESI the private island. Do notVarythe whole HTML shell onCookieto personalize a nav bar.
Cache key composition
Default shared-cache key is roughly method + scheme + host + path + query, then extra dimensions from Vary. Vendors let you customize that set; they do not magically know which query params matter.
| Piece | Include? | Normalize how |
|---|---|---|
| Method | Yes | GET and HEAD may share; never mix POST into a GET object |
| Scheme | Yes | http vs https are different objects unless you redirect first |
| Host | Yes | Lower-case; drop default ports; pick one apex vs www |
| Path | Yes | Collapse //, decide trailing slash, usually lower-case for ASCII |
| Query | Only params that change bytes | Sort by name; allow-list (id, page, v) rather than block-list |
| Selected headers | Only true variants | Sorted name=value; never raw Cookie or Authorization |
| Body | Rarely | Hash POST/PUT bodies if you insist on caching GraphQL POST |
Allow-list beats block-list. New trackers appear every quarter (fbclid, gclid, mc_eid). A block-list of utm_* is always incomplete. If the origin only keys on id and color, the cache key should too.
Trailing slash and case are silent origin DDoS. /P and /p/ and /p are three objects unless you canonicalize before lookup.
What Vary actually does
Vary is a response header. It tells every cache: "this body is not valid for every request to this URL; fold these request headers into the key."
Vary: Accept-Encoding, Accept-LanguageAfter that, Accept-Encoding: br and Accept-Encoding: gzip are different objects (unless the CDN stores one uncompressed variant and transcodes). Forgetting Vary when the origin did switch on a header serves the wrong variant to the next user. Adding Vary for a header the origin ignored just fragments the cache.
RFC 9111 also defines Vary: * — treat the response as uncacheable for shared reuse. That is a self-own on a CDN.
Accept-Encoding and Accept-Language
Encoding. Browsers send messy Accept-Encoding lists (gzip, deflate, br vs br, gzip). If you key on the raw string you store the same JPEG many times. Let the CDN negotiate compression: store one object, or store negotiated encoding (br vs gzip vs identity) rather than the header verbatim. Double-compression is the other bug: caching an already-gzipped body and then gzipping again because Vary: Accept-Encoding made the edge treat it as uncompressed.
Language. en-US, en-GB, en, en-us are one English page for most sites. Bucket to the languages you actually render (en, fr, de) and send Vary: Accept-Language on that bucket. Unlimited language tags explode keys the same way cookies do.
Device and image format (Accept, Sec-CH-UA) belong in the key only when the bytes change. Prefer distinct URLs (photo.webp) over Vary: Accept on a generic /photo.
Decisions
- 1
Step 1 Incoming request
- nextStep 2 Strip non-content cookies?
- ?
Step 2 Strip non-content cookies?
- static pathDrop Cookie before lookup
- needs A/B cookieKeep only ab=blue or ab=green
- 3
Drop Cookie before lookup
- nextStep 3 Canonical host + path + allow-listed query
- 4
Keep only ab=blue or ab=green
- nextStep 3 Canonical host + path + allow-listed query
- 5
Step 3 Canonical host + path + allow-listed query
- nextStep 4 Fold Vary axes: encoding, language bucket
- 6
Step 4 Fold Vary axes: encoding, language bucket
- nextStep 5 Final key
- 7
Step 5 Final key
- nextStep 6 Lookup
- ?
Step 6 Lookup
- hitServe cached variant
- missFetch origin, store under this key
- 9
Serve cached variant
- 10
Fetch origin, store under this key
Lesson map
Cache Keys & Vary
The cache key is the identity of a variant. Cookie in Vary or raw query strings explode cardinality; normalize host/path and put only true content axes in the key.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["Step 1 Incoming request"] b["Step 2 Strip non-content cookies?"] c["Drop Cookie before lookup"] d["Keep only ab=blue or ab=green"] a -->|Step 1 Incoming request| b b -->|static path| c b -->|needs A/B cookie| d
Cookie stripping and personalization
Cookies are high-cardinality and often unique per session. Vary: Cookie on a cacheable path means one object per shopper. Hit ratio collapses; origin QPS climbs; a misconfigured public cache can also leak a personalized page if you stored Set-Cookie HTML under a key that later matches someone else.
Practical rules:
- Strip
Cookieon/static/*, hashed assets, and public product HTML that does not change per user. - Split hostnames:
static.shopnever sees cookies;www.shopmay. - If you A/B test, whitelist one stable cookie (
ab=blue) into the custom cache key — do notVaryon the whole jar. Authorizationand session cookies →Cache-Control: private/no-storeunless you have a signed-URL design.
Edge-side includes (ESI) and fragment caches are how you keep a public shell in the shared cache while the cart icon stays private. That is a key-design move, not a TTL move. Freshness knobs live on SWR / stale-if-error.
Vendor knobs (same idea, different names)
| Feature | Fastly | Cloudflare | Self-hosted Varnish |
|---|---|---|---|
| Default key | Host + URL + ordered query | Host + URL + ordered query | vcl_hash on URL + host |
| Custom key | VCL hash / cache key | Cache Rules / custom cache key | vcl_hash |
| Ignore query params | Ignore lists in VCL | Cache key ignore query | std.querysort + unset |
| Cookie | Unset req.http.Cookie on static | Bypass / cookie ignore | Unset in vcl_recv |
Vary | Honored; you can still hash extra | Honored; custom key can replace it | You must hash what you Vary |
Akamai Property Manager has the same knobs (query allow-lists, cache-key includes) under different names. Do not memorize product labels in an interview. Say allow-list query, strip cookies, hash only content headers.
The object on disk is usually a hash of the key, not the key string. Two conceptually identical requests that differ by an extra header still miss. www vs apex, :443, and HTTP vs HTTPS are different hosts/schemes unless you 301 before lookup. Pick one canonical host at the edge; do not store both.
ESI / fragments. If the page is 95% public and 5% "Hello, Ada," do not Vary the shell on Cookie. Cache the shell under a tight key; include a private fragment (ESI, edge worker, or browser fetch). That is complementary to SWR: SWR refreshes an object you already named; ESI keeps the name small.
Deep dive · POST bodies and GraphQL
GET-with-query is the cacheable shape. Teams still cache GraphQL POST by hashing the body into the key. That works only if the body is deterministic (sorted keys, no client timestamps) and auth is not in the body. If the query is user-specific, you are back to per-user keys — cache at the application layer instead. Never fold Authorization into a shared object.
Playground: normalizer + Vary explosion
In-memory only. No fetch. Two policies: a naive key that keeps cookies and trackers, and a tight key that drops them and buckets language. Watch unique-key counts.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Run it: naive cardinality tracks cookies × langs × encodings × query order. Tight cardinality is one path × encodings negotiated × language buckets — here two encodings times one en bucket.
Interview Q&A
What does the Vary header do?
Answer
It is a response header listing request headers that were inputs to the representation. Shared caches must store a different variant per distinct combination of those header values (after any vendor normalization). Omit it when the origin actually varied, and the next user can be served the wrong language or encoding.
Why must you not put Cookie in a shared cache key unless you have a hard reason?
Answer
Cookies are usually unique per session. The key space becomes one object per user, hit ratio falls toward zero, and origin QPS climbs to request rate. If the object is personalized and you accidentally treat it as public, you can also leak one shopper's HTML to another. Strip cookies on static paths; whitelist a stable A/B cookie if you must vary.
How does Accept-Encoding fragment the cache?
Answer
Raw header strings differ (gzip, br vs br, gzip) even when the negotiated encoding is the same. Each string becomes a variant. Prefer storing one object and compressing at the edge, or key on the negotiated encoding only. Also avoid gzipping a body the origin already compressed.
How do you cache language variants without exploding keys?
Answer
Bucket tags you actually render (en-US and en-GB → en). Include the bucket in the key and send Vary: Accept-Language. Do not key on the full quality-value list. If you only ship English and French HTML, two variants is the budget.
What is a canonical query string and why does it matter?
Answer
Deterministic order plus filtering. Sort parameter names; drop trackers; keep only params that change bytes. Without that, /p?id=1&x=2 and /p?x=2&id=1 are two origin fetches for one page. Allow-list is safer than a tracker block-list.
When would you deliberately ignore a Vary header?
Answer
When the origin sent Vary: User-Agent (or Cookie) on a response whose bytes do not change with that header — a static image, a hashed JS bundle. Ignoring it collapses variants and restores hit ratio. Confirm with the origin owners first; if bytes do change, ignoring Vary is a correctness bug. Prefer fixing the origin to stop sending the extra Vary.
What belongs in the cache key for a public product page?
Answer
https + lower-case host + normalized path + allow-listed query (id, maybe color) + negotiated encoding. Not utm_*, not fbclid, not the session cookie. If you A/B the hero image, add one stable experiment cookie or a distinct URL.
Missing Vary vs over-Vary — which is worse?
Answer
They fail differently. Missing Vary is a correctness incident (wrong language, mixed encoding). Over-varying is a capacity incident (origin stampede, CDN bill). Staff answers name both. See also request collapsing: a fat key also defeats coalescing, because waiters no longer share a key.
Should scheme and trailing slash live in the key?
Answer
Scheme yes — HTTP and HTTPS are different resources unless you 301 first. Trailing slash should not produce two objects: canonicalize to one path at the edge before lookup, then 301 the other. Same for ASCII case on hosts.
How do ESI and SWR relate to keys?
Answer
ESI (or fragments) lets the public shell use a tight key while a small private include stays uncacheable. SWR does not fix a bad key — it only hides revalidation for an object you already named. Purge groups (generation tokens / surrogate keys) also assume you can name the object.
Does the CDN store the key string or a hash?
Answer
Usually a hash of the composed key. You never see it. The operational point is that any extra dimension (header, unsorted query, different host spelling) produces a different hash and a miss. Debug with vendor cache-status headers and by logging the normalized key your edge computes, not the raw URL.
Pitfalls
| Pitfall | What actually happens |
|---|---|
Over-varying (User-Agent, Referer, raw Cookie) | Combinatorial key explosion; origin sees almost every request |
Missing Vary | Wrong language or encoding served from a public HIT |
| Case-sensitive header values | en-US vs en-us splits one language into two objects |
| Double compression | Edge gzips an already-gzipped body because encoding handling is sloppy |
| Cookie leakage | Personalized HTML stored under a key another user can HIT |
| Unsorted query | Semantically identical URLs miss each other |
| Block-list only for trackers | Next campaign param (gclid) fragments the cache again |
Caching POST without a body hash | Collisions or never matching; GraphQL is the usual trap |
Write the key for GET https://shop.cdn/P/?id=42&utm_source=ad with Accept-Language: en-US and a session cookie. Then change query order, cookie value, and en-GB. Count how many objects a naive key creates versus a tight one. Put the two counts on the whiteboard before you talk TTL.
Go Deeper
- MDN — Vary
- MDN — Cache-Control
- Cloudflare — Cache keys
- RFC 9111 — HTTP Caching
- Fastly — Cache freshness
Sibling pages: CDN hierarchy · SWR / stale-if-error · Purge and generation tokens · TLS / Anycast · Request collapsing