CDN Optimization Starts With the Cache Key

Categories:
Business
Date:

September 20, 2026

Time:
5 min

Most CDN optimization work starts by raising TTLs and stops when the hit-ratio chart looks better. Then the origin egress bill does not move. The cause is almost always the cache key: the CDN is storing dozens of near-identical copies of the same object, and every one of them misses on first request. This piece covers what actually controls origin load — key cardinality, revalidation, and tiering — and the order to fix them in.

Cache hit ratio is the wrong number to run on

Cache hit ratio counts requests. Your origin bill counts bytes. The two diverge the moment object sizes vary, which is every real catalogue.

‍

Fastly draws the distinction explicitly: origin offload is "the ratio of bytes served to end users that were cached inside the CDN (not fetched from the origin), over total bytes served to end users for the service" (Fastly blog, accessed 2026). One large video segment miss outweighs thousands of hits on thumbnails, and hit ratio will not show it. The same write-up documents a customer who disabled shielding: hit ratio stayed in the low 90s while origin load went from under 5 GiB/s to over 20 GiB/s. A metric that can miss a 4× change in origin load is not a control surface.

‍

There is also a scaling effect people get backwards. Moving hit ratio from 90% to 95% is not a 5% improvement — the miss rate goes from 10% to 5%, so origin requests are cut in half. The last few points hold all the money, which is exactly why header changes alone rarely buy them.

‍

What is CDN optimization? CDN optimization is the work of reducing how much traffic reaches your origin without serving stale or incorrect content. It has three levers: shrinking the cache key so fewer duplicate objects exist, setting freshness and revalidation headers so objects survive longer at the edge, and adding a tiered or shield layer so edge misses do not all reach origin.

‍

In our migration work the first thing we ask for is not the hit-ratio dashboard. It is origin egress in bytes per hour, split by content type. That number tells you whether the problem is fragmentation, expiry or catalogue shape, and those have different fixes.

The cache key decides everything TTL cannot

A cache key is the string the CDN hashes to decide whether it already has your object. Anything in it that varies per user, per campaign or per browser multiplies your stored copies and drives every copy through a first-request miss.

‍

Cloudflare's default key is the full URL including query string, plus the Origin header and the method-override and forwarding headers (x-http-method-override, x-forwarded-host, x-original-url and several others) (Cloudflare docs, accessed 2026).

‍

Know which half of the fix is gated. Sort query string, Ignore query string, Cache deception armor and Cache by device type are ordinary Cache Rules settings. What Enterprise buys is the custom cache key: selecting named query parameters to include or exclude, and adding headers, cookies, host or user attributes to the key (Cloudflare docs, accessed 2026). So canonicalising parameter order is a toggle on most plans; surgically keeping ?variant= while dropping ?utm_source= is not, and below Enterprise that job belongs in your application or a Worker.

‍

Here is what fragments a key in production, with the documented behaviour behind each:

Cache key input Documented behaviour Why it fragments Fix
Query string order parameter1=a&parameter2=b and parameter2=b&parameter1=a are fetched and cached separately "even if they're identical" (AWS docs, accessed 2026) Every permutation becomes its own object Sort parameters before the key is computed, or forward an allowlist only
Query string case parameter1=A and parameter1=a produce separate origin requests (AWS docs, accessed 2026) SDKs, email tools and hand-built links are inconsistent Normalise case at the edge
Tracking parameters Included by default, because the default key is the full URL with query string (Cloudflare docs, accessed 2026) One campaign can spawn thousands of variants of one page Strip utm_*, gclid, fbclid from the key; keep them client-side for analytics. Selective exclusion is Enterprise-only on Cloudflare
Accept-Encoding CloudFront cache policies normalise it to Accept-Encoding: br,gzip (or gzip) and exclude every other viewer value from the key (AWS docs, accessed 2026) Browsers send many value permutations Enable the cache policy's Gzip/Brotli settings and let it normalise. Never simply drop the variance — see the warning below
User-Agent AWS advises against headers "with numerous unique values" and points to the CloudFront-Is-*-Viewer headers instead (AWS docs, accessed 2026) Cardinality is effectively unbounded Vary on device class, never raw UA
Vary A stored response cannot be reused unless the nominated header fields match, and Vary: * never matches (RFC 9111 §4.1, 2022) Each varied header multiplies stored copies Vary on the smallest possible set
Cookies Both platforms allow cookie values in the key; AWS advises forwarding only named cookies (AWS docs, accessed 2026) Session cookies are unique per user, so hit ratio approaches zero Split cache behaviours so static paths never see cookies

‍

That is the specification. The platform you are on may not implement it, and this is the single most consequential divergence in the whole article.

‍

Cloudflare states it plainly: "By default, Cloudflare does not consider vary values in caching decisions." Vary values are respected only "when you configure the Cache Rules Vary setting, when Vary for images is configured, and when the vary header is vary: accept-encoding" (Cloudflare docs, accessed 2026). A response containing Vary: * always bypasses cache regardless of that configuration (Cloudflare docs, accessed 2026).

‍

Read what that means for the common case. If you shipped Vary: Cookie on a cacheable page and assumed it protected per-user content, it did not fragment the cache and it did not disable it — by default the header was ignored and one stored object was served to everyone. That is a cross-user content leak, not a hit-ratio problem, and it fails in the direction that ends up in an incident report. Only Vary: * gives you the bypass. Treat Vary as a correctness control you must explicitly enable per platform, not as something the origin can assert and trust.

TTL and revalidation are a negotiation with the platform

RFC 9111 fixes the precedence. A cache calculates freshness lifetime from s-maxage if it is a shared cache, then max-age, then Expires minus Date, and only then falls back to heuristics (RFC 9111 §4.2.1, 2022). Where heuristics apply and a Last-Modified header exists, the spec encourages "no more than some fraction of the interval since that time. A typical setting of this fraction might be 10%" (RFC 9111 §4.2.2, 2022).

‍

That precedence is the most underused lever on the list. Split the edge lifetime from the browser lifetime: hold an object at the edge for a day while browsers revalidate in five minutes, so a purge on publish reaches every user immediately and the origin still sees almost nothing.

‍

Which header does the splitting depends on the platform, and this is where vendor-agnostic matters. On Varnish-derived stacks, Surrogate-Control takes precedence over everything else: Fastly documents its order of preference as Surrogate-Control: max-age, otherwise Cache-Control: s-maxage, otherwise Cache-Control: max-age, otherwise Expires (Fastly docs, accessed 2026). Surrogate-Control is also stripped before the response reaches the browser, which makes it the cleanest way to say "one hour at the edge, one minute in the browser" without the browser ever seeing the edge value. Use Surrogate-Control where it is honoured and s-maxage where it is not.

‍

Platform defaults then sit on top of the spec, and they differ enough to matter:

Behaviour Cloudflare Amazon CloudFront Fastly
HTML cached by default No — "The Cloudflare CDN does not cache HTML or JSON by default" (Cloudflare docs, accessed 2026) Yes, subject to cache behaviour and policy Yes, subject to service configuration
Freshness precedence s-maxage → max-age → Expires per RFC 9111 Same, bounded by the policy's TTL settings Surrogate-Control: max-age → s-maxage → max-age → Expires (Fastly docs, accessed 2026)
TTL when origin sends no directives 120 minutes for 200/206/301; 20 minutes for 302/303; 3 minutes for 404/410 (Cloudflare docs, accessed 2026) Default TTL 86,400 seconds, applied "only when your origin does not add" Cache-Control/Expires (AWS docs, accessed 2026) 2 minutes (Fastly docs, accessed 2026)
Ceiling on origin-set TTL Not stated on the default-behaviour page Maximum TTL default 31,536,000 seconds, applied only when the origin does send those headers (AWS docs, accessed 2026) Not stated on the cache-freshness page
Platform config can override no-cache Not stated on the default-behaviour page Yes — "If your minimum TTL is greater than 0, CloudFront will cache content for at least the duration specified in the cache policy's minimum TTL, even if the Cache-Control: no-cache, no-store, or private directives are present" (AWS docs, accessed 2026) Not stated on the cache-freshness page

That last row is the single most dangerous default in CDN configuration, and it is documented rather than hidden. It is also almost never mentioned in optimization guides, because it is a correctness risk rather than a performance tip.

‍

Freshness expiry then creates a second problem: a synchronised miss. Every edge holding a copy expires at roughly the same second and every one of them fetches. Your origin sees a periodic spike with no traffic change behind it.

‍

RFC 5861 solves that in one directive. stale-while-revalidate "indicates that caches MAY serve the response in which it appears after it becomes stale, up to the indicated number of seconds" (RFC 5861 §3, 2010), and stale-if-error allows a stale response when the origin returns 500, 502, 503 or 504 (RFC 5861 §4, 2010). AWS recommends supplementing max-age with both (AWS docs, accessed 2026).

‍

How do you improve CDN cache hit ratio? Reduce the cache key first: strip tracking parameters, normalise query string order and case, forward only named cookies and headers, and keep Vary to the smallest set. Then extend freshness with s-maxage and add stale-while-revalidate so expiry does not force a synchronous origin fetch. Enable a tiered or shield layer last.

‍

stale-if-error also converts a short origin outage into cached-content delivery rather than a 502 page. We treat it as an availability control, not a caching one, and it is the cheapest one available.

‍

Then check that your platform will actually honour it. Cloudflare documents that "the stale-if-error directive is ignored if Always Online is enabled or if an explicit in-protocol directive is passed", and that those directives "include a no-store or no-cache cache directive, a must-revalidate cache-response-directive, or an applicable s-maxage or proxy-revalidate cache-response-directive" (Cloudflare docs, accessed 2026). Read that carefully: on Cloudflare, the s-maxage you just added to extend edge lifetime disables stale-if-error on the same response. The two most commonly recommended directives in CDN optimization advice cancel each other out on one of the largest CDNs, and no guide that treats headers as portable will tell you. Pick one per platform — s-maxage where you want edge/browser split and Always Online is off, stale-if-error where availability during an origin outage matters more — or use Surrogate-Control on stacks that honour it and leave s-maxage off the response entirely.

‍

One more thing the bytes-not-requests thesis demands: a revalidation is not a miss. When an object goes stale and the origin answers a conditional request with 304 Not Modified, you paid one request and almost no bytes. That is why ETag and Last-Modified belong on every cacheable response, not just the ones you plan to soft-purge. A site with aggressive revalidation can show a mediocre hit ratio and an excellent egress bill at the same time, and it will look like a problem on the dashboard your vendor gives you.

Tiering and shielding beat every header change

If edge PoPs each fetch independently, one object costs you as many origin requests as you have caching locations. A tier in front of the origin collapses that.

‍

CloudFront states it directly: "When you use Origin Shield, all requests from all of CloudFront's caching layers to your origin come from a single location. CloudFront can retrieve each object using a single origin request from Origin Shield, and all other layers of the CloudFront cache (edge locations and regional edge caches) can retrieve the object from Origin Shield" (AWS docs, accessed 2026).

‍

Cloudflare's equivalent is Tiered Cache. Smart Tiered Cache, which picks a single closest upper tier per origin, is available on all plans; Generic Global Tiered Cache and Custom Tiered Cache topologies are Enterprise features, as is the Regional Tiered Cache middle layer on the Tiered Cache documentation (Cloudflare docs, accessed 2026). Verify this one against the live docs before you plan around it — Cloudflare is currently moving parts of the tiering stack under the Smart Shield brand, and the availability tables on the two sets of pages do not yet agree.

‍

Most audits get the order wrong here. Teams spend three weeks tuning headers and never turn on the upper tier, when tiering is a toggle with a larger effect than every header change combined. Check one thing first: whether your platform bills the shield layer as its own request tier. Offload that arrives as a new line item is still worth having, but it is not free, and it belongs in the [CDN cost comparison → /blog-posts/cdn-pricing-comparison] before you commit.

Purging is a hit-ratio decision in disguise

Purge-all at deploy time is a self-inflicted origin incident: every edge misses simultaneously on the next request for every object.

‍

Surrogate keys are the alternative — tag responses with keys and purge by tag. Individual keys are capped at 1,024 bytes and the total header at 16,384 bytes; beyond that "the key currently being parsed and all keys following it within the same header will be ignored" (Fastly docs, accessed 2026). Tag a category page with its product identifiers and a price change purges exactly the pages that display it.

‍

Soft purge is the second half. It marks content outdated while keeping it usable — "stale objects remain available to use in some circumstances while Fastly fetches a new version from origin" (Fastly docs, accessed 2026) — and requires either ETag/Last-Modified on origin responses or stale_while_revalidate configured together with stale_if_error. Without that groundwork it does close to nothing.

The optimization sequence, in the order that pays

  1. Measure origin egress in bytes per hour, split by content type. Not hit ratio. If bytes are flat and requests are spiky, you have an expiry problem; if both are high, you have a key problem.
  2. Dump the actual cache key your platform computes for your top 20 URLs by origin fetches. Most fragmentation is visible in one look.
  3. Strip tracking parameters and normalise query string order and case before the key is computed.
  4. Reduce Vary to the minimum set, remove Vary: Cookie from anything you intend to cache, and confirm whether your platform honours Vary at all — on Cloudflare it is off by default and must be enabled in Cache Rules. Never rely on Vary alone to keep per-user content out of a shared cache.
  5. Split cache behaviours by path so static assets never carry cookies or session headers. This, not Vary, is what actually protects authenticated content.
  6. Split edge lifetime from browser lifetime — Surrogate-Control: max-age where it is honoured, otherwise s-maxage long with max-age short — and add stale-while-revalidate. Add stale-if-error only after checking the platform: on Cloudflare an applicable s-maxage or must-revalidate, or Always Online being on, disables it (Cloudflare docs, accessed 2026).
  7. Put ETag or Last-Modified on every cacheable response so stale objects revalidate into a 304 instead of a full refetch.
  8. Enable tiered caching or origin shield, then re-measure origin bytes — this is the step with the largest single effect.
  9. Replace purge-all with tag-based soft purge and wire it into the deploy pipeline.
  10. Re-baseline after seven days. Cache fill has to complete across the footprint before any number means anything.

What breaks in production

Logged-in HTML cached at the edge. A minimum TTL above zero overriding no-cache, no-store and private (AWS docs, accessed 2026) is how one user's account page gets served to another. This is the failure that ends careers, and it is a two-field misconfiguration.

‍

Vary trusted as an access control. The same outcome by a different route. Cloudflare does not consider vary values by default (Cloudflare docs, accessed 2026), so a page shipping Vary: Cookie is stored as one object and served to every visitor. The dangerous part is that this looks better on your dashboard — hit ratio goes up — right up to the point someone sees another user's session. Path-based cache separation is the control; Vary is a hint your platform may ignore.

‍

"We raised the TTL and nothing happened." On Cloudflare, HTML and JSON are not cached by default (Cloudflare docs, accessed 2026). The TTL was raised on content the CDN was never storing.

‍

Compression variance removed instead of normalised. "Shrink the cache key" and "drop Accept-Encoding" read as the same advice and are not. Removing the variance without removing the variation ships a Brotli-encoded body to a client that asked for identity, or an uncompressed body to everyone. Normalise the header — CloudFront's cache policy compression settings collapse it to br,gzip (AWS docs, accessed 2026) — and never strip Vary: Accept-Encoding from a response whose body actually varies by encoding.

‍

Soft purge that does nothing. Without ETag/Last-Modified or stale_while_revalidate and stale_if_error configured together, soft purge has nothing to serve from (Fastly docs, accessed 2026).

‍

Header advice copied between platforms. The s-maxage plus stale-if-error pairing is the clearest case — correct on CloudFront, self-cancelling on Cloudflare (Cloudflare docs, accessed 2026). Caching headers are a specification; honouring them is a per-vendor decision. Test the response, do not trust the article, including this one.

‍

Hit ratio improves, bill does not. Almost always fragmentation on large objects: the small files got better and the heavy ones still miss. Check bytes, not requests.

‍

Cache fill measured too early. Any change to the cache key invalidates the entire stored set, so the first day after always looks worse. Judging it on day one is how good changes get reverted.

The ceiling no vendor will mention, and how to decide

Your hit ratio has a ceiling set by your catalogue, not your configuration.

‍

If most of your objects are requested once — a large media library, user-generated storage, a long-tail download archive — edge caching adds a hop and a fill cost and cannot reach a high hit ratio at any TTL. The honest fixes are a tiered topology that concentrates fills, a shorter path between origin and users, or a provider whose pricing model suits long-tail delivery rather than one that prices as if you were a news site.

‍

Every platform's optimization guide is written to make its own cache look effective. None will tell you that your content shape is the binding constraint, because the conclusion is sometimes "this is the wrong CDN for your workload" — see [how to switch CDN providers without downtime → /blog-posts/cdn-shutdown-migration-playbook] for what that migration costs. We say it because we sell across providers rather than one of them.

‍

So take the branch that matches your measurement:

‍

  • Origin bytes high, hit ratio high. Fragmentation on large objects. Fix the cache key, starting with query strings and Vary.
  • Origin bytes spiky on a fixed interval. Synchronised expiry. Add stale-while-revalidate and stagger TTLs across object classes.
  • Origin requests high, bytes moderate. Every PoP fetching independently. Turn on tiering or shield before touching a single header.
  • Hit ratio structurally low across everything. Catalogue shape, not configuration. Re-examine topology and provider fit, and read [where egress fees actually land → /blog-posts/cloud-egress-fees-comparison] before you renegotiate.
  • Hit ratio fine, bill still wrong. The problem is the rate card, not the cache. Start from [optimizing CDN costs → /blog-posts/optimize-cdn-costs-strategies-and-best-practices].

‍

What is a good cache hit ratio? There is no universal target, because the ceiling depends on how often your objects are re-requested. A media catalogue with long-tail access cannot reach the same ratio as a news site serving the same assets to everyone. Judge a hit ratio against your own baseline and against origin bytes, not against a published benchmark.

‍

We run this sequence across whichever CDNs a client already uses, and the deliverable is the origin-bytes delta, not a hit-ratio chart. Having the cache key on your top-20 origin fetches dumped and read before you change anything is the place to start — and if the answer turns out to be topology rather than headers, a [multi-CDN routing setup → /blog-posts/multi-cdn-strategy] is the next conversation, not another round of TTL edits.

FAQ

Does raising TTL always improve cache hit ratio? No. TTL only matters for objects the CDN is already storing under a stable key. If tracking parameters or Vary headers are fragmenting the key, a longer TTL preserves more duplicate copies rather than fewer, and the first request for each still reaches origin. Fix key cardinality before touching freshness values.

‍

What is the difference between cache hit ratio and origin offload? Cache hit ratio measures the share of requests served from cache. Origin offload measures the share of bytes served to users that did not come from origin (Fastly blog, accessed 2026). Because object sizes vary, the two can move in opposite directions. Origin offload tracks your egress bill; hit ratio does not.

‍

Should I use max-age or s-maxage? Both. s-maxage applies to shared caches and takes precedence over max-age in them (RFC 9111 §4.2.1, 2022). Setting s-maxage long and max-age short holds objects at the edge while keeping browsers honest, so a purge on publish reaches every user without your origin absorbing the expiry.

‍

Is origin shield worth enabling on a small site? Usually yes on request volume, and it should be checked on cost. A shield collapses many independent PoP fetches into a single origin request per object (AWS docs, accessed 2026). The caveat is that some platforms bill the shield layer as its own request tier, so confirm the rate card before assuming the offload arrives free.

‍

Why did our cache hit ratio drop after a configuration change? Any change to the cache key invalidates every stored object, so the entire footprint refills from origin. That refill period looks identical to a regression. Hold the change for at least a full traffic cycle — seven days on most sites — before comparing, and compare origin bytes rather than hit ratio.

‍

Can a CDN cache a page my application marked no-cache? Yes. CloudFront documents that a minimum TTL above zero causes caching "even if the Cache-Control: no-cache, no-store, or private directives are present in the origin headers" (AWS docs, accessed 2026). Audit minimum TTL on every cache behaviour that can serve authenticated paths before you trust your origin headers to protect you.

‍

INXY Hosting solutions

Ready to strengthen your digital defenses? Schedule a demo call with our experts today to discover how INXY can help protect your business against cyber threats and ensure uninterrupted operations.

Book a Demo

Read more articles