What Goes in the CDN Cache Key: How utm_ Parameters and Cookies Wreck Your Hit Ratio
Caching Strategies ended with the observation that the same key/TTL/coalescing questions follow you to the network edge — here they are, with a bigger blast radius, because an edge mistake multiplies across dozens of locations. Your CDN reports a 94% hit ratio for images and 6% for JavaScript. The difference is not the content — both are immutable files. The difference is that every marketing link, email, and in-app reference to the bundle arrives as app.js?utm_source=newsletter&utm_campaign=march, and the CDN's cache key is built from the URL including the query string. One file, four hundred variants, four hundred misses, four hundred origin fetches.
A CDN is a cache keyed by something. The default is:
cache key = scheme + host + path + (all query strings) + (forwarded cookies) + (Vary headers)
Everything in the key gets its own copy; everything not in the key is shared. That gives you two levers, and mis-setting either is a measured, repeatable performance bug.
Query strings. Decide per URL class, not globally:
| URL class | Key should contain | Why |
|---|---|---|
Fingerprinted assets (app.8f3a9c.js) |
Path only; ignore all query strings | The hash is the version; ?utm_* variants must collapse to one entry |
Versioned-by-parameter (lib.js?v=4.2) |
Allowlisted v only |
Keeps real cache-busting, drops tracking noise |
Search / listing (/search?q=…&page=…) |
All functional params | Each combination is genuinely a different response |
Email/social links with utm_* |
Strip at the edge before lookup | Tracking must not fragment your cache |
The fix is usually an edge rule or cache policy that either ignores query strings for asset paths or allowlists the two parameters that matter. Cost of getting it wrong, measured: at 800 requests/second for a 300 KB bundle, a 6% hit ratio means ~750 origin fetches/s ≈ 225 MB/s of origin egress you are paying for, plus origin CPU — for bytes that never change.
Cookies. Legacy CDN configurations forwarded every cookie into the key. A session cookie is unique per user, so index.html cached "per user" is not cached at all — every request is a miss and the CDN becomes an expensive proxy. Forward nothing by default; allowlist only the cookie that changes rendering (say, a locale preference). If personalization requires cookies, that is a dynamic problem — chapter three — not a reason to put user identity in the static cache key.
Vary. The Vary header extends the key with request headers: Vary: Accept-Encoding is correct and gives you one copy per encoding (gzip, br) — expect a 2–3× multiplication that is worth it. Vary: User-Agent multiplies your keyspace by browser, OS, and bot variants and usually gains nothing; Vary: * means "never reuse this response." Audit Vary responses in your CDN logs before accepting them.
Whatever the key, the freshness instructions ride along in headers:
# fingerprinted asset — safe to keep for a year
Cache-Control: public, max-age=31536000, immutable
# semi-dynamic API/HTML — short freshness, background revalidation
Cache-Control: public, max-age=60, stale-while-revalidate=300
# never at any shared cache
Cache-Control: no-store
The payoff is latency, not just egress: an edge hit lands 8–15 ms from the user versus 120–250 ms round-trip to an origin on another continent, which is most of your LCP budget spent on one header decision. Get the key right and the rest of CDN work — freshness, purge, dynamic content — behaves; get it wrong and no TTL tuning will save your hit ratio. The keys also decide what you can throw away, which is the next topic after freshness.
TTLs, Purges, and stale-while-revalidate: Buying Freshness Without Losing the Edge
Your price page is wrong for four minutes after a promotion ends. The fix isn't "shorter TTLs everywhere" — that trades away the edge hit ratio you just built. Freshness at the CDN is a portfolio decision: per content type, choose how stale is tolerable, how fast you can actively purge, and what users see while revalidation runs.
Read the directives in order of authority:
| Directive | Meaning | Use for |
|---|---|---|
Cache-Control: no-store |
Never store at any shared cache | Account pages, tokens, /api/me |
s-maxage=… |
Freshness for shared caches (CDNs), overrides max-age |
Let browsers and CDN disagree on purpose |
max-age=… |
Freshness for this response everywhere | Default per URL class |
stale-while-revalidate=N |
Serve stale up to N s while fetching fresh in background | HTML, search, config — freshness without user-visible latency |
stale-if-error=N |
Serve stale up to N s when the origin fails | Availability during origin outages |
| (no header at all) | Heuristic freshness from Last-Modified age |
Never rely on this — always send explicit max-age |
Two properties matter in interviews. First, Age is a response header: it tells you how many seconds of TTL are already spent, which is how you debug "why is this edge serving old bytes?" without guessing. Second, revalidation is cheap — If-None-Match/ETag returns 304 Not Modified with no body — so a longer max-age plus revalidation rarely means re-transferring the file.
Active purge is a safety net, not a strategy. Purge APIs (invalidate a path or tag) propagate globally in seconds to a few minutes depending on provider — Fastly documents roughly 150 ms for a global purge; larger networks take longer under load — and they are rate-limited and sometimes billed beyond a free tier (CloudFront includes 1,000 paths/month). That latency is a correctness window: during it, the old asset is live at some edges. Plan for it rather than discovering it in an incident.
The stronger strategy is to make most purges unnecessary with content-addressed versioning:
Deploy order that avoids 404s:
1. upload dist/app.8f3a9c.js + app.41b7e02.css (new names, immutable, max-age=1y)
2. fetch and confirm 200 from the CDN
3. publish index.html with new URLs (max-age=60, stale-while-revalidate=300)
4. old assets: delete from origin later, keep serving from edge until TTL ends
Because the filename changes, the new URL is a miss once and forever a hit; the old URL keeps working for stragglers. No purge of assets ever happens. Only HTML — one small file — needs short TTLs or a purge. This is the single highest-leverage pattern in CDN work: never overwrite a cached URL; mint a new one.
Reserve purge for things you cannot version: a legal takedown, a mis-configured response shipped with wrong headers (purge the class of paths), a broken image. And set stale-if-error=86400 on critical routes: if the origin dies at 02:00, the CDN can keep serving yesterday's catalog for a day while you recover — degraded freshness, full availability.
Negative caching deserves a line too: a 404 with max-age=60 stops hammering an origin for nonexistent assets, but it also delays the moment a just-published file becomes visible — purge or use a short TTL on 4xx responses.
Everything so far caches public, URL-addressed content. The hard version — responses that differ per user — is where CDN incidents come from, and it follows next.
Caching Dynamic Responses at the Edge Without Serving One User's Data to Another
The incident writes itself: a CDN caches /api/me after stripping Authorization and Cookie from the cache key so the endpoint could "cache like everything else." User A's dashboard — name, plan, last invoice — is stored under that URL, and for the next 60 seconds every user who hits that path receives A's account. Cache poisoning of personal data is one of the most severe bugs a CDN misconfiguration can produce, and it comes from applying the wrong mental model: the cache key must uniquely identify everything the response depends on.
So the question is never "can the edge cache dynamic content?" — it does, constantly — but "what does this response actually vary by, and can I afford that cardinality?"
Strategy 1 — cache only what is truly URL-determined. Product pages, search results, documentation: the response is a pure function of URL (+ allowlisted headers). Forward no cookies, strip identity before origin lookup, and make sure the origin ignores them too. A CDN is safe here only because the origin was designed not to personalize this route.
Strategy 2 — split the page: cache the shell, no-store the personal parts. Cache the static template at the edge; have it reference /api/recommendations?slot=… marked no-store, filled client-side or via an edge function. Personalized data never enters a shared cache; the 200 KB shell still comes from 10 ms away. This is the default architecture for logged-in web apps.
Strategy 3 — personalize at the edge. An edge worker runs on every hit: it reads the public cached fragment, decodes the signed session cookie (already validated at the edge), and injects the user's name/plan into the HTML before returning it. The stored object stays public; the per-user output is computed, never cached. Cost: a few milliseconds of worker time; benefit: one round trip, zero origin involvement.
Strategy 4 — signed URLs/cookies for access without key fragmentation. A private PDF is one shared cached object; each request carries a signature the edge validates before serving. Identity guards access, not storage — the cache key contains the resource path, not the user. (Keep signatures out of the stored key, or validate-then-lookup.)
What you must not do casually:
- Per-user cache keys (
/dashboard?user=123in the key): correct, but the keyspace is users × pages — millions of entries, LRU thrash, near-zero hit ratio, and every one of them a data-retention question. Reserve it for a small set of hot, non-sensitive segments (locale, device class), never for identity. - Strip-and-serve: removing
Authorizationfrom the key while the origin still uses it. Either the origin ignores identity for this route (document it) or identity stays in the key. Never half of each. - Blind
Varyexpansion:Vary: Cookieon a dynamic route yields one copy per session — same fragmentation as per-user keys with none of the control.
Also use the edge's own miss-suppression: request collapsing collapses simultaneous misses for one key into a single origin fetch per POP, and an origin shield layer collapses misses across POPs — during a flash sale, 40,000 edge misses for one URL become a handful of origin requests. Dynamic or not, that is the property you are buying.
Interview framing: state the invariant plainly — the cache key must encode every input the response varies by, and nothing else — then classify the route (public / shell+fragment / edge-personalized / uncacheable) and defend the cardinality. Once you can do that at the edge, the remaining question is what to do when the data underneath changes: Cache Invalidation Trade-offs.
Quiz
1. Your fingerprinted JavaScript bundle gets a 6% CDN hit ratio because every reference carries different utm_ parameters. What is the correct fix?
2. You need to replace a widely cached image with a new version and cannot tolerate users seeing the old one. Which rollout avoids purge latency entirely?
3. A private PDF must be served through the CDN to many authorized users. What belongs in the cache key?
4. What does Cache-Control: max-age=60, stale-while-revalidate=300 actually do for an edge cache hit?
Flashcards
Question
What does a default CDN cache key contain, and what are the two classic ways to destroy a hit ratio with it?
Click to reveal answer
Answer
Scheme + host + path + query strings + forwarded cookies + Vary-selected headers. The two classics: tracking parameters (utm_*) making one fingerprinted asset hundreds of "different" objects, and session cookies in the key making per-user copies of pages that are identical for everyone.
Question
How should fingerprinted assets, search URLs, and account pages be cached, and with what directives?
Click to reveal answer
Answer
Fingerprinted assets: path-only key, max-age=31536000, immutable — never purged, replaced by new filenames. Search/listing URLs: all functional query params in the key, max-age=60 with stale-while-revalidate=300. Account pages: no-store, plus identity kept in the key if any shared caching is attempted.
Question
When is it safe to cache a dynamic response at the edge, and what invariant governs the key?
Click to reveal answer
Answer
Safe when the response varies only by inputs you include in the key: the invariant is the key must encode every input the response depends on and nothing else. Classify routes: public URL-determined (cache with identity stripped at origin too), shell + no-store fragments, personalization computed at the edge from a public cached base, or uncacheable. Per-user keys are correct only if you can afford the users x pages cardinality.
Question
Purge or version — when do you use each, and what does a purge actually cost in time?
Click to reveal answer
Answer
Version (mint a new URL) for anything you control: no propagation window, old assets keep serving until TTL ends, purge APIs and rate limits never enter the picture. Purge for what cannot be versioned: takedowns, bad headers, mis-published content. Purge propagation is seconds to minutes globally (Fastly documents ~150 ms), so it is a correctness window, not instant.
Question
What do request collapsing and origin shield do during a flash sale?
Click to reveal answer
Answer
They collapse concurrent misses for the same object into one origin fetch — collapsing per point of presence, origin shield as a shared tier that collapses across POPs. Tens of thousands of edge misses for one URL become a handful of origin requests, which is why dynamic-but-public routes can be edge-cached safely.
Revision Notes
Key Takeaways
- 1. The cache key is the whole game: query strings, cookies, and Vary each multiply the keyspace — every input in the key gets its own copy, every input left out is shared across users
- 2. Fingerprinted assets with path-only keys and max-age=31536000, immutable remove purge from the workflow entirely: never overwrite a cached URL, mint a new one
- 3. Purge is a bounded propagation window (seconds to minutes, rate-limited), not instant — treat it as a correctness window in your incident and release plans
- 4. Worst-case dynamic leakage comes from half-applied rules: stripping Authorization from the key while the origin still personalizes on it — key and origin must agree on what is public
- 5. stale-while-revalidate and stale-if-error convert freshness and outage problems into bounded staleness; edge miss collapsing protects the origin during exactly the spikes you built the CDN for
Interview Tips
- • "Your CDN hit ratio is 6% on assets — diagnose it." — walk the key: utm parameters and cookies in the key fragment identical content; show the fix (allowlist or ignore query strings for fingerprinted paths) and the egress math it saves
- • "How would you deploy a breaking frontend change without users seeing mixed versions?" — asset-first ordering: upload new fingerprinted files, verify, publish HTML last; explain why purge is unnecessary and where stale-if-error fits as a safety net
- • "Would you cache a personalized page at the CDN?" — answer with the invariant (key must cover every input), then classify: shell+fragment or edge personalization; only per-user keys are correct if cardinality is affordable
- • "Someone else's account data was served from your CDN — what happened?" — identity stripped from the key while the origin still varied on it; the fix is agreeing on the public boundary plus no-store on all identity-bearing routes, not shorter TTLs
Cheat Sheet
CDN and Edge Caching
Cache key = everything the response varies by, and nothing else:
key = scheme + host + path + allowlisted query params + forwarded cookies + Vary headers
| URL class | Key contents | Directive |
|---|---|---|
| Fingerprinted asset | Path only (strip utm_*) | max-age=31536000, immutable |
| Param-versioned asset | Allowlisted v only |
long max-age |
| Search / listing | All functional params | max-age=60, stale-while-revalidate=300 |
| HTML shell | Path | short max-age + purge or revalidate |
| Session / account | — | no-store |
Freshness levers: s-maxage (shared caches) > max-age; stale-while-revalidate = background refresh, no user wait; stale-if-error = origin-down serving (set 86400 on critical routes); no header = heuristic freshness — always send explicit max-age.
Change strategy: never overwrite a cached URL — upload app.8f3a9c.js, verify 200, then publish HTML pointing at it. Purge (seconds–minutes, rate-limited) is only for takedowns and mistakes.
Dynamic routes: public → cache with identity stripped at origin; personal → cache shell + no-store fragments; edge-personalized → public base + edge worker injects user data; private → shared object + signature validated at edge.
Miss collapse: request collapsing per POP + origin shield across POPs turn 40,000 concurrent misses into a handful of origin fetches.