1. The Ticket Said "Turn On The CDN"
A lighting retailer came to me in March with a Lighthouse report and a theory. Their TTFB was 940ms on mobile, the report was scolding them about it, and someone on the marketing side had read that a CDN fixes TTFB. The ticket, verbatim, was "enable CDN on all pages". Two days of work, they thought.
They already had a CDN. It had been in front of the site for three years. It was serving 96% of their image requests from cache and 4% of everything else, and the 4% was the part the customer was waiting for.
The reason was a single line in their origin's response headers. Every HTML response carried Cache-Control: no-cache, no-store, must-revalidate, inherited from a session-handling change made in 2022 to stop a logged-in customer's basket leaking into a cached page for an anonymous one. Sensible instinct. Blunt execution. Every product page, every category page, every blog post went to origin, every time, for every visitor, from every country.
So the CDN was doing what a CDN does when you tell it nothing is cacheable: acting as an expensive extra hop. Sydney to the edge in Sydney, then Sydney to the origin in Frankfurt, then back. The edge added about 15ms of its own and saved nothing.
This article is about what a CDN actually does to the numbers, where the wins come from, and how to tell whether yours is working. It is deliberately about running one CDN well, because that is the situation almost every store is in and almost every store is leaving something on the table. If you have already got one CDN working properly and are wondering whether to add a second, that is a genuinely different problem and I have written about multi-CDN architecture separately. Do not read that one first. It will make you want to buy things.
2. What A CDN Actually Removes From TTFB
Time to First Byte is not one thing. It is a stack of things, and a CDN removes some of them completely, some of them partially, and some of them not at all. Getting this straight is what stops you buying an edge network to solve a database problem.
Break a TTFB into its parts, in the order they happen:
DNS resolution. Turning your hostname into an address. A CDN usually improves this, because its authoritative nameservers are anycast and near the client, whereas your registrar's default nameservers may be in one place. Typical saving on a cold lookup: 20–60ms. Not nothing, and completely invisible in most people's measurements because they test from a machine that has the answer cached.
TCP handshake. One round trip. Purely a function of distance and network quality between the client and whatever it is connecting to. This is where the CDN earns its keep: instead of a round trip to Frankfurt, the customer in Sydney does a round trip to a box in Sydney. That is the difference between roughly 280ms and roughly 12ms.
TLS negotiation. One more round trip on TLS 1.3, two on 1.2. Same logic, same saving, doubled. If you want the detail on that, the TLS 1.3 handshake is worth understanding properly, because the difference between a resumed and a full handshake is larger than most people assume.
Request travel time. The request itself crossing the network.
Origin think time. Your PHP or Node process building the page. Database queries, template rendering, third-party API calls to your ERP for stock levels.
Response travel time. The first byte coming back.
On a cache miss, the CDN removes nothing from the middle three. It removes some of the first three. It adds a small amount of its own — one extra hop, some header processing, occasionally a TLS termination and re-establishment to origin — and if the origin connection is not kept warm, it can add a lot.
On a cache hit, everything from "request travel time" onwards collapses to a disk read on a machine near the customer. That is the entire value proposition, and it is enormous, and it only happens if you cache.
The number that actually matters
Here is the arithmetic that I put in front of the lighting retailer, because it made the argument better than I could.
| Component | No CDN | CDN, cache miss | CDN, cache hit |
|---|---|---|---|
| DNS | 45ms | 18ms | 18ms |
| TCP | 280ms | 12ms | 12ms |
| TLS | 280ms | 12ms | 12ms |
| Edge to origin | — | 295ms | — |
| Origin think time | 310ms | 310ms | — |
| Response travel | 140ms | 150ms | 3ms |
| TTFB | 1055ms | 797ms | 45ms |
Real figures from an Australian test location against a Frankfurt origin, rounded. The middle column is what their site was doing. The right-hand column is what it could do. The gap is not a feature you can buy; it is a cache policy you have to write.
Note also that the miss column is worse than the no-CDN column in one place — response travel, because there is an extra hop. A CDN that never hits is a small net negative for local users and a moderate net positive for distant ones.
3. Distance Is Not The Only Latency
The mental model everyone has is that a CDN moves bytes closer. That is true and it is not the whole story, and the rest of the story is why a CDN can help even a store whose customers are all in one country.
Congestion window. TCP does not start at full speed. It starts with a small congestion window — ten packets on most modern stacks, roughly 14KB — and grows as acknowledgements come back. Growth is paced by round-trip time. A long-RTT connection takes several round trips to reach a useful throughput, so a 60KB HTML document over a 280ms link is not one round trip of transfer, it is four or five. Terminating the connection at an edge node with a 12ms RTT means the window opens almost immediately.
Warm origin connections. A decent CDN keeps persistent connections open to your origin. So even on a miss, the edge is not paying for DNS, TCP and TLS to your origin — it is reusing a pipe that is already open and already has a wide congestion window. This is a real and underrated benefit and it is one reason the miss column above is not worse than it is.
Better routing. BGP optimises for policy and cost, not latency. Large CDNs have private backbones and direct peering with the major eyeball networks, so edge-to-origin traffic can take a materially better path than the client's would have. I have seen 40ms shaved off a UK-to-US origin fetch purely from routing, with no caching involved.
Absorbing bursts. Your origin has a finite number of PHP-FPM workers. A CDN with a good hit rate means a traffic spike hits the edge, not the workers. This is not a latency benefit until it is — the moment your worker pool saturates, TTFB goes from 300ms to 8 seconds, and that is the failure mode that actually costs money.
So the honest framing is: a CDN is a caching layer that happens to also be nearby. The nearby part helps. The caching part is the point.
4. The Cache Key Is The Whole Game
Everything about CDN performance reduces to one question: for a given request, what string does the edge use to look up its cache?
That string is the cache key. By default it is roughly scheme plus host plus path plus query string, and every CDN lets you modify it. The two failure modes are symmetrical and both are common.
Too specific and you fragment. Ten variants of the same page cached separately because the key includes a tracking parameter that changes per visitor. Hit rate collapses. Every unique key is a guaranteed miss on first request, and if the long tail is long enough, the object gets evicted before it is ever served twice.
Too general and you leak. One customer's cached page served to another. Currency, language, tax-inclusive pricing, and — the one that ends careers — a logged-in header showing someone else's name.
Getting this right is fiddly and it is the highest-leverage work in this entire article.
Query strings are where hit rate goes to die
A product page URL that has been shared on Facebook, run through an email campaign, and clicked from a Google Ad looks like this by the time it reaches your edge:
/products/oak-dining-table?fbclid=IwAR2x9k...&utm_source=newsletter&utm_medium=email&utm_campaign=spring&gclid=Cj0KCQ...
Those four parameters change per click. If they are in the cache key, that URL is unique, forever, for every single visitor. Your product page has a 0% hit rate and you will not notice, because your overall hit rate is dominated by images.
The fix is to normalise. Strip the parameters that do not change the response; keep the ones that do.
// Cloudflare Worker: normalise the cache key before lookup.
// Anything not on the allowlist is discarded — analytics params, click IDs,
// affiliate tags, and the endless supply of junk that appears in the wild.
const KEEP = new Set([
'page', // pagination changes the response
'sort', // so does sort order
'colour', // and a variant selector
'size',
]);
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const params = [...url.searchParams.keys()];
for (const name of params) {
if (!KEEP.has(name)) url.searchParams.delete(name);
}
// Sort what survives, so ?sort=price&page=2 and ?page=2&sort=price
// are the same object rather than two.
url.searchParams.sort();
// Look up and store under the normalised URL, but send the ORIGINAL
// request to origin on a miss, so server-side analytics still see the
// campaign parameters.
return fetch(request, { cf: { cacheKey: url.toString() } });
},
};
That last comment matters more than it looks. If you strip UTM parameters before the request reaches origin, and your analytics runs server-side, you have just deleted your campaign attribution to fix your hit rate. Marketing will find out in about nine days and they will be right to be annoyed. Strip from the key, not from the request.
Most platforms have a version of this. Fastly calls it a VCL edit, Akamai a "cache ID modification" behaviour, Shopify does it for you and does not let you touch it. On Nginx it is a map block and a proxy_cache_key.
# Nginx as an edge cache: build a key from path plus a whitelisted
# subset of arguments, plus the things that genuinely change the response.
map $arg_page $key_page { default ""; ~. "p=$arg_page"; }
map $arg_sort $key_sort { default ""; ~. "s=$arg_sort"; }
# Device class comes from a header your CDN or a UA parse sets upstream.
# Two buckets, not fifty. Every bucket halves your effective hit rate.
map $http_user_agent $device {
default "d";
"~*iphone|android" "m";
}
proxy_cache_key "$scheme$host$uri|$key_page|$key_sort|$device|$cookie_currency";
The dimensions that legitimately belong in the key
Be miserly here. Every dimension multiplies your object count and divides your hit rate. On a storefront, the defensible list is short:
Currency or market, if prices differ. Language, if you serve more than one and do not use separate paths. Device class, and only if you genuinely render different HTML for mobile — if you are responsive, and you should be, this is zero dimensions rather than two. Logged-in versus anonymous, which is better handled by not caching the logged-in case at all. And that is close to the complete list.
Things that do not belong: session ID (ever), any per-user token, the full user agent string, and geolocation at any granularity finer than "the countries where my prices differ". I once inherited a configuration that varied on city-level geo for a store with a single price list. Two hundred and forty cache variants of a homepage that was byte-identical in all of them.
5. Vary, Cookies, And Why Your Hit Rate Is Four Percent
Two headers do more damage to cache hit rates than everything else combined, and both are usually set by something you did not write.
Vary: Cookie tells the cache that the response depends on the request's cookies, and since every visitor has a different session cookie, every response is unique. Hit rate: zero. Frameworks emit this defensively — Magento does it, Django does it the moment you touch the session, Rails does it — because it is the safe default and the framework does not know you have put a CDN in front.
Set-Cookie on a cacheable response is worse, because most CDNs will refuse to cache a response carrying it, and they are right to. If they did cache it, every subsequent visitor would receive someone else's session cookie. A single analytics library that sets a first-party cookie server-side can silently make your entire HTML uncacheable.
Check for it directly. This is the first thing I run on any store where the hit rate looks wrong:
# What is the origin actually saying about cacheability?
# -s silent, -o discard the body, -D - dump headers to stdout.
curl -s -o /dev/null -D - https://origin.example.com/products/oak-dining-table \
| grep -iE 'cache-control|vary|set-cookie|age|expires|surrogate'
# And what does the edge think, on two consecutive requests?
# The second should report a hit. If it does not, that is your answer.
for i in 1 2; do
curl -s -o /dev/null -D - https://www.example.com/products/oak-dining-table \
| grep -iE 'cf-cache-status|x-cache|age'
done
The fix is to strip both at the edge for the paths where they are wrong, which means being explicit about which cookies genuinely affect the response.
// Strip everything except the cookies that legitimately change the page.
// Run this on the request before cache lookup; run the Set-Cookie removal
// on the response before storing.
const MEANINGFUL = ['currency', 'store_locale'];
function stripCookies(request) {
const raw = request.headers.get('Cookie') || '';
const kept = raw
.split(';')
.map(c => c.trim())
.filter(c => MEANINGFUL.some(name => c.startsWith(name + '=')));
const headers = new Headers(request.headers);
if (kept.length) headers.set('Cookie', kept.join('; '));
else headers.delete('Cookie');
return new Request(request, { headers });
}
function makeCacheable(response) {
const headers = new Headers(response.headers);
// The origin's session cookie must not be stored and re-served.
headers.delete('Set-Cookie');
// And the framework's defensive Vary would defeat the whole exercise.
headers.delete('Vary');
return new Response(response.body, { ...response, headers });
}
I want to be honest about the risk in that second function. Deleting Vary and Set-Cookie from a response you are about to store in a shared cache is exactly the operation that leaks one customer's data to another if you get the conditions wrong. Do it only for paths you have positively identified as anonymous — product pages, category pages, content pages — and never as a global rule. I gate it on the request having no session cookie at all, which means logged-in users bypass the cache entirely and get a slower but correct page. That trade is nearly always the right one on a storefront, because the logged-in population is a small fraction of traffic and a large fraction of your legal exposure.
6. Reading Your Hit Rate Honestly
Your CDN dashboard shows one big number. That number is a lie, or at least it is answering a question you did not ask.
An overall hit rate is dominated by whatever you request most, and on a storefront that is images. A product page with thirty images and one HTML document gives you a 96.7% hit rate if every image hits and the HTML misses every time. The dashboard says 96.7%. The customer waits for the HTML.
Segment it. At minimum, split by content type, and ideally by template.
// Paste in the console on a real page. Groups resource timings by
// extension and reports how many were served from a nearby cache
// versus how long they actually took.
const groups = {};
for (const e of performance.getEntriesByType('resource')) {
const ext = (new URL(e.name).pathname.split('.').pop() || 'html').slice(0, 5);
groups[ext] ??= { n: 0, ms: 0, bytes: 0 };
groups[ext].n++;
groups[ext].ms += e.duration;
groups[ext].bytes += e.transferSize || 0;
}
console.table(
Object.entries(groups)
.map(([ext, g]) => ({
ext,
requests: g.n,
avgMs: Math.round(g.ms / g.n),
kb: Math.round(g.bytes / 1024),
}))
.sort((a, b) => b.avgMs - a.avgMs)
);
The number I actually care about is the HTML hit rate for anonymous traffic on your top three templates. That is a single figure, it is usually embarrassing, and it predicts your field TTFB better than anything else on the dashboard.
There is a second number worth pulling out, which most dashboards bury: the ratio of hits to revalidations. A revalidation is a conditional request to origin — the edge has the object, it has expired, so it asks the origin whether it is still good. The origin returns 304, the edge serves its copy. That is counted as a hit by some providers and it is not free: it cost a full round trip to origin. If your revalidation count is high, your TTLs are too short and your dashboard is flattering you.
7. What To Cache
The categories, in descending order of how obvious they are.
Fingerprinted static assets. Anything whose filename contains a content hash — app.7f3c2a.js, main.9b21e4.css — is immutable by construction. Cache it for a year and tell the browser to never revalidate. There is no risk, because a change produces a new filename.
Cache-Control: public, max-age=31536000, immutable
The immutable directive is the part people leave off, and it does real work: without it, a browser reload issues a conditional request for every asset, so a customer who hits F5 pays a round trip per file even though everything is cached. With it, the browser does not ask.
Images. Long TTLs, but not usually a year, because product photography does get replaced and the filenames are frequently not fingerprinted. A month at the edge with a purge on product update is the pattern I use. If you are also doing format negotiation, be careful — that is a Vary: Accept situation and it is one of the few places Vary is legitimate. See the notes on serving WebP and AVIF for how to avoid caching a WebP for a browser that cannot render it.
Product and category HTML for anonymous visitors. This is the big one and it is the one people skip. A product page for a not-logged-in visitor is the same bytes for everyone in the same market. Cache it. Short TTL if you are nervous, long TTL plus a purge hook if you are not.
Search and autocomplete responses. The head of the query distribution is tiny and repeats constantly. Ninety seconds of caching on /api/search?q= for the top thousand queries removes a startling amount of load from an Elasticsearch cluster.
Sitemaps, robots.txt, feeds. Trivial, frequently uncached, and requested by crawlers with a persistence that will surprise you.
Stock and price fragments — carefully. Ten to sixty seconds. Long enough to absorb a burst, short enough that nobody sees a wrong number for long. On a fast-moving marketplace, not acceptable at all.
8. What To Never Cache
Shorter list, and the consequences of getting it wrong are not "slow page", they are "incident".
Cart contents. Checkout, at every step. Customer account pages. Order history and order confirmation. Anything behind authentication. Admin, obviously, and I have seen an admin panel cached at the edge exactly once, which was enough.
The correct posture is an explicit bypass rule, matched on path, evaluated before anything else, with an audit that runs in CI. Not a default that you hope holds.
# Bypass is an allowlist inverted: named paths never cache, full stop.
# Evaluated first, before any of the caching logic below it.
location ~ ^/(checkout|cart|customer|account|admin|api/session) {
proxy_pass http://origin;
proxy_cache off;
add_header Cache-Control "private, no-store, max-age=0" always;
# Belt and braces: if a downstream shared cache ignores Cache-Control,
# this at least tells CDNs that honour it.
add_header CDN-Cache-Control "no-store" always;
}
And then test it, because a rule that exists in one config file and not in the one that is actually deployed is worth nothing:
#!/usr/bin/env bash
# Run in CI after every deploy. Any of these being cacheable is a
# stop-the-line failure, not a warning.
set -e
HOST="https://www.example.com"
for path in /checkout /cart /customer/account /admin; do
cc=$(curl -s -o /dev/null -D - "$HOST$path" | grep -i '^cache-control:' | tr -d '\r')
case "$cc" in
*no-store*) echo "ok $path" ;;
*) echo "FAIL $path -> $cc"; exit 1 ;;
esac
done
9. Caching HTML: The Argument Both Ways
This is the decision that separates a CDN that saves 40ms from one that saves 700ms, and it is the one people are most frightened of. Reasonably so.
The case against is straightforward. HTML on a storefront often contains personalisation — a name in the header, a cart count, recently viewed items, a market-specific price. Cache it and you serve one person's page to another. The failure is silent, it is visible to the customer, and under GDPR it is reportable if it includes anything identifying.
The case for is that the overwhelming majority of your traffic is anonymous, that page is identical for all of them, and it is the single largest component of TTFB.
The resolution is not to pick a side, it is to separate the document from the personalisation. Three ways to do that, in ascending order of effort.
Cache the shell, fetch the personal bits
Serve a fully cached HTML document with placeholder markup for the personal parts, then fill them in with a small uncached request after load. The cart badge, the customer name, the "you viewed this" strip.
<!-- Cached HTML. The placeholder reserves layout so filling it in
later does not cause a shift. -->
<div id="account-strip" data-personal style="min-height:1.5rem">
<a href="/customer/account/login/">Sign in</a>
</div>
<script>
// One uncached request, after paint, for everything personal at once.
// Batching matters: three separate calls means three round trips.
fetch('/api/session-summary', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data || !data.name) return;
document.getElementById('account-strip').innerHTML =
'Hello, ' + data.name + ' — ' + data.cartCount + ' items';
})
.catch(() => { /* leave the cached default in place */ });
</script>
This is the pattern Magento's full page cache uses, and it works. The cost is a flash of the anonymous state before the personal state arrives, which on a slow connection can be a second or more, and a layout shift if you have not reserved the space. Reserve the space.
Edge-side includes
ESI lets the edge assemble a page from cached fragments with different TTLs. The page shell caches for an hour, the price block for a minute, the stock indicator for ten seconds.
<div class="price-block">
<esi:include src="/fragments/price/SKU-4471" />
</div>
<div class="stock">
<esi:include src="/fragments/stock/SKU-4471" />
</div>
I have a complicated relationship with ESI. It works, it is supported by Varnish, Fastly and Akamai, and it is genuinely the right answer for a page with several independently-changing regions. It is also a distributed templating system with no local development story, poor debuggability, and error semantics that will ruin an afternoon — a fragment that 500s can blank a section of the page with no obvious signal. I reach for it on large catalogues where the alternative is a 15-second TTL on everything, and I avoid it otherwise.
Compute the personalisation at the edge
Workers, Lambda@Edge, Fastly Compute. Cache the anonymous document, then rewrite it at the edge using something cheap you already have — a signed cookie, a geo header — before it goes to the client. This keeps the personalisation server-side, removes the flash, and adds a few milliseconds of edge CPU.
It is the best answer technically and the most operationally involved. You now have code deployed in a third place, with its own release cycle and its own outage mode. On a store with a small team I would not introduce it to solve a cart badge.
10. Cache-Control, Explained By What Breaks
The directives are documented everywhere and understood nowhere, largely because the documentation explains what they mean rather than what goes wrong when you get them wrong.
public vs private. private means browsers may cache it but shared caches may not. Get this wrong in the permissive direction and a CDN caches a personal page. Get it wrong in the restrictive direction and your hit rate is zero and you will not know why, because the response looks cacheable to a casual reading.
max-age. Seconds. Applies to every cache that does not have a more specific instruction. The failure mode is setting a long one on HTML and then needing to change something, at which point browsers that already have it will not ask again for the duration and you have no way to reach them. You can purge a CDN. You cannot purge a browser.
s-maxage. Overrides max-age for shared caches only. This is the directive that makes HTML caching survivable, because it lets you say "edge, hold this for an hour; browser, hold it for sixty seconds". The edge you can purge. The browser you cannot.
Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400
That single line is the workhorse for storefront HTML and it is worth reading slowly. Browser holds it for a minute. Edge holds it for an hour. For a day after the edge copy expires, the edge may serve it stale while fetching a fresh one in the background. The worst-case user-visible staleness is a minute; the worst-case origin load is one request per URL per hour.
no-cache vs no-store. These are not synonyms and the naming is genuinely awful. no-cache means "store it, but revalidate before every use" — the object is on disk, there is a conditional request, and a 304 saves the body. no-store means "do not write this down anywhere". If you want a checkout page to never touch a cache, you want no-store. Half the internet writes no-cache and gets a conditional request per view, which is slow rather than dangerous, so nobody notices.
Provider-specific headers. Surrogate-Control (Fastly, Varnish) and CDN-Cache-Control (Cloudflare, Akamai, and now standardised) let you instruct the CDN without the browser ever seeing it. This is cleaner than the s-maxage trick when your CDN supports it, because the browser-facing header stays honest and readable.
Cache-Control: public, max-age=60
CDN-Cache-Control: max-age=86400
Surrogate-Control: max-age=86400, stale-while-revalidate=604800
Surrogate-Key: product-4471 category-dining collection-oak
11. Stale-While-Revalidate, And The Header That Saves Your Weekend
stale-while-revalidate changes the shape of your traffic more than any other single directive. Without it, an expiring object means the next visitor waits for a full origin fetch. With it, that visitor gets the stale copy instantly and the refresh happens behind them.
On a busy page this is the difference between one slow request per TTL and none. On a page with a long tail it is the difference between a thundering herd and a gentle trickle.
stale-if-error is the one I actually evangelise. It says: if the origin returns a 5xx or times out, serve the stale copy rather than the error.
Cache-Control: public, s-maxage=600, stale-while-revalidate=3600, stale-if-error=86400
A homeware client's origin database fell over on a Saturday afternoon during a sale. The origin was returning 500s for eleven minutes. Because stale-if-error=86400 was set on category and product HTML, anonymous browsing continued working the entire time from stale edge copies. Checkout was down, which is not nothing, but customers could browse, and the ones already mid-session did not see an error page. We got a handful of "the site's a bit slow" emails instead of a public incident.
Eleven minutes of degraded service instead of eleven minutes of outage, from one directive that costs nothing. Set it on everything anonymous.
12. Purging: Four Strategies, Ranked
The reason people set short TTLs is that they do not trust their ability to invalidate. Fix the invalidation and you can set long TTLs, and long TTLs are where the hit rate lives.
Purge by URL
Simplest. You changed a product, you purge that product's URL. It works and it is insufficient, because that product also appears on three category pages, the homepage's "new in" strip, a collection page, the sitemap, and a search result. Purging one URL leaves six stale.
Purge everything
The nuclear option, and I understand the appeal. The problem is what happens next: every URL is now a miss, every miss goes to origin, and your origin receives its full uncached traffic in a single burst. I have watched a store take itself down with a purge-all during a Black Friday price update. The purge succeeded. The origin did not.
If you must, do it at 4am, and warm the top thousand URLs immediately afterwards.
Purge by cache tag
This is the right answer and it is available on every serious CDN under a different name — surrogate keys on Fastly, cache tags on Cloudflare Enterprise and Akamai, X-Magento-Tags if you are running Varnish in front of Magento.
The origin declares, on every response, which entities the page depends on. Later, when an entity changes, you purge the tag and every page that declared it disappears from cache, wherever it was.
<?php
// Emitted by the application on every cacheable response.
// Every entity the page reads from becomes a tag; changing any of
// them invalidates exactly the pages that depend on it.
$tags = [];
$tags[] = 'product-' . $product->getId();
$tags[] = 'price-' . $product->getId();
$tags[] = 'stock-' . $product->getSku();
foreach ($product->getCategoryIds() as $categoryId) {
$tags[] = 'category-' . $categoryId;
}
if ($product->getBrandId()) {
$tags[] = 'brand-' . $product->getBrandId();
}
// Fastly and Varnish read Surrogate-Key; Cloudflare reads Cache-Tag.
// Emitting both is harmless and makes a provider change less painful.
header('Surrogate-Key: ' . implode(' ', array_unique($tags)));
header('Cache-Tag: ' . implode(',', array_unique($tags)));
Then the invalidation, fired from wherever your product save hook lives:
# Purge every cached object that declared a dependency on product 4471.
# One call, regardless of how many category and collection pages
# happened to include it.
curl -X POST "https://api.fastly.com/service/$FASTLY_SERVICE/purge/product-4471" \
-H "Fastly-Key: $FASTLY_TOKEN" \
-H "Accept: application/json"
# Cloudflare's equivalent takes a batch, which is worth using —
# one request for a whole catalogue import beats four thousand.
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data '{"tags":["product-4471","category-12","brand-9"]}'
The mistake I made the first time I built this was tagging too finely. A tag per attribute — colour, material, dimensions — on the theory that granularity was free. It is not: providers cap tags per response, and I blew past Fastly's header size limit on category pages listing sixty products. Those responses silently lost their keys and became unpurgeable. Tag at the entity level: product, category, brand, plus a global tag for the layout.
Do not purge at all
The most reliable invalidation is a URL that never needs it. Fingerprint your assets, version your API paths, and put a content hash in any URL you can. For everything that survives, a short s-maxage with a long stale-while-revalidate gets you most of the benefit of a long TTL with none of the purge machinery. On a small catalogue that changes twice a day, this is genuinely the correct engineering decision and building a tag system would be over-engineering.
13. Origin Shielding And Tiered Caching
A CDN with 300 points of presence and a cold cache means up to 300 separate misses for the same object, each hitting your origin. On a purge-all, that is 300 times your object count.
Origin shielding designates one location — ideally close to your origin — as the only tier that talks to origin. Every other edge, on a miss, asks the shield. The shield asks origin once and answers everyone else.
The effect on origin load is dramatic and the effect on latency is mildly negative for the second-tier miss, since there is now an extra hop. That trade is nearly always worth it. On the homeware store, enabling a Frankfurt shield in front of a Frankfurt origin took origin requests per minute from around 2,400 to around 310 with no change to any cache policy.
Where it is not worth it: if your origin is itself globally distributed, or if your platform already does this invisibly. Check before you pay for it.
14. Compression And The Edge
Two things interact here in a way that surprises people.
First, if your origin serves uncompressed responses and the CDN compresses them at the edge, you are paying for compression CPU on every miss and shipping uncompressed bytes over the edge-to-origin link. Compress at origin. Better still, pre-compress your static assets at build time so nothing compresses anything at request time — I have written up the Brotli and gzip trade-offs at length.
Second, compression and caching share a failure mode: Vary: Accept-Encoding. It is correct and necessary, and it means the edge stores a separate object per encoding. That is fine — two or three variants, and every modern client sends the same thing. It becomes a problem when a middlebox or an old client sends a mangled Accept-Encoding and you end up with dozens of variants. Normalise the header at the edge into two buckets, br and gzip, and cache against those.
// Normalise Accept-Encoding to at most two values before cache lookup.
// Without this, "gzip;q=1.0, deflate, br" and "br, gzip" are different
// cache entries containing identical bytes.
function normaliseEncoding(request) {
const ae = (request.headers.get('Accept-Encoding') || '').toLowerCase();
const value = ae.includes('br') ? 'br' : ae.includes('gzip') ? 'gzip' : '';
const headers = new Headers(request.headers);
if (value) headers.set('Accept-Encoding', value);
else headers.delete('Accept-Encoding');
return new Request(request, { headers });
}
15. The Honest Section About SEO
Here is where I part company with most articles carrying a title like this one's.
A CDN is not a ranking factor. There is no CDN signal. Google does not know or care which provider serves your bytes. What it observes is response time and availability, and those feed into things that do matter, weakly and indirectly.
Three real mechanisms, in descending order of how much they are worth:
Crawl budget. This is the one people underrate. Googlebot allocates a crawl rate partly based on how fast your server responds; if responses are slow, it backs off to avoid hurting you. On a small site this is irrelevant, because your whole catalogue gets crawled regardless. On a site with 400,000 URLs it is very relevant indeed, because the difference between a 200ms and an 800ms average response is roughly the difference between your new products being indexed in a day and in a week. I have watched a store cut average response time by 60% and see indexed URL count climb over the following six weeks with no other change. That is a real effect and it is a crawling effect, not a ranking one.
Core Web Vitals. Page experience is a confirmed but small ranking input, and it is a tiebreaker rather than a lever. A CDN improves TTFB, and TTFB is a component of LCP — often 30–40% of it on a slow origin. So the chain is: CDN improves TTFB, improves LCP, contributes to a signal that is one of many. Each link in that chain is real and each one attenuates. If your LCP is 4.2s because of a 3MB hero image, no CDN will save you; the fixes for the metrics themselves are a different piece of work.
Availability. Underrated because it is invisible when it works. A site that 500s when Googlebot arrives gets crawled less, and repeated failures can drop pages from the index. stale-if-error at the edge means a crawler sees a 200 with slightly old content instead of a 503. That is worth more than most on-page work.
And here is what a CDN does not do for SEO, despite what you will read: it does not help you rank in a country because you have a PoP there. Geographic ranking comes from your domain, your hreflang, your content, your local links and your Search Console targeting. Serving bytes from São Paulo does not make you Brazilian.
If someone quotes you a percentage — "a CDN improves rankings by 15%" — they made it up. The mechanism is response time, the effect is indirect, and the correct expectation is "this helps a bit and helps more the larger your site is".
16. Measuring Whether Any Of This Worked
Lab tools will tell you your TTFB from a datacentre in Virginia with a warm cache. That is not your customer.
Measure from the field. The Navigation Timing API gives you TTFB directly, and reporting it alongside the cache status the edge told you about is what turns a number into a diagnosis.
// Real-user TTFB, tagged with the edge's own cache verdict.
// The Server-Timing header is how you get the CDN to tell you, in a
// form the browser exposes to JavaScript.
addEventListener('load', () => {
const nav = performance.getEntriesByType('navigation')[0];
if (!nav) return;
// Origin must send: Server-Timing: cdn-cache;desc=HIT, edge;dur=12
const timings = Object.fromEntries(
(nav.serverTiming || []).map(t => [t.name, t.description || t.duration])
);
navigator.sendBeacon('/rum', JSON.stringify({
url: location.pathname,
ttfb: Math.round(nav.responseStart),
// How much of TTFB was connection setup vs waiting for bytes
connect: Math.round(nav.connectEnd - nav.domainLookupStart),
wait: Math.round(nav.responseStart - nav.requestStart),
cache: timings['cdn-cache'] || 'unknown',
country: document.documentElement.dataset.geo || '',
}));
});
Then look at the distribution, not the mean. TTFB is bimodal on a site with a partial hit rate: a tight cluster around 50ms for hits, a long smear from 400ms upwards for misses. The mean sits between the two humps and describes nobody. Report the 75th percentile, split by cache status, split by country. That table is the one that tells you what to do next.
Synthetic monitoring still has a place: catching configuration regressions. A daily check that checkout is still no-store and product pages still hit is worth more than another Lighthouse score.
17. A Worked Example, Including What Went Wrong
Back to the lighting retailer. UK-based, Frankfurt origin on a managed Magento host, 18,000 SKUs, meaningful traffic from Australia and the west coast of the US.
Where we started. Cloudflare in front, on since 2021. Overall hit rate 94%, which everyone was pleased with. HTML hit rate: 0%. Field TTFB at p75: 940ms in the UK, 1,740ms in Australia. Every HTML response carried no-cache, no-store, must-revalidate and a Vary: Cookie for good measure.
Week one: find out why. The no-store traced back to a 2022 commit fixing a genuine bug where a logged-in header was served to an anonymous visitor. The fix was correct for the bug and far too broad. Nobody had revisited it because nothing was visibly wrong.
Week two: separate personal from public. Moved the cart badge, the account name and the recently-viewed strip into a single /api/session-summary call. Made the header markup render an anonymous default with reserved height. This was the bulk of the work — about six days, most of it spent finding places in the theme where a template quietly read the session.
Week three: cache policy. Product, category and content pages moved to public, max-age=0, s-maxage=3600, stale-while-revalidate=86400, stale-if-error=604800. max-age=0 rather than 60 because they wanted price changes visible immediately on refresh and were willing to pay a conditional request for it. Checkout, cart and account got an explicit bypass with a CI check.
Week three, also: cache keys. Query normalisation with an allowlist of four parameters. Cookie stripping down to currency and nothing else. This alone moved the category page hit rate from 0% to 71%, because a startling proportion of their category traffic arrived with a gclid attached.
Week four: tags and purge. Surrogate keys per product, category and brand, wired into the product save observer and the nightly ERP import. Purge-all was removed from the deploy script and replaced with a tag purge for the global layout tag.
What went wrong. Two things, and the second one was mine.
The first was a price display bug that ran for about four hours on the Tuesday of week three. Prices are shown tax-inclusive for UK visitors and tax-exclusive for trade accounts, and the trade flag lived in a cookie we had just stripped. Trade customers, who are a small but very vocal fraction of that business, saw consumer pricing on category pages. Caught by a customer phone call, not by us. The fix was to add the trade flag to the cache key as a boolean, which cost one extra cache variant and would have cost nothing had I audited what read from cookies before deciding which to strip.
The second was worse in intent if not in effect. I set s-maxage=3600 on the homepage before wiring up the tag purge, over a weekend, on the assumption that nothing would change. Marketing changed the hero banner on Saturday morning for a flash sale and it did not appear for an hour. The sale ran for six hours. They lost the first sixth of it. Long TTLs and no invalidation is a combination you get away with until the one time you do not, and I knew better.
Where it ended up. HTML hit rate 82% for anonymous traffic on product and category templates. Field TTFB at p75: 210ms in the UK, 290ms in Australia. Origin requests per minute at peak went from about 2,400 to about 340, and they downgraded their hosting tier at renewal, which paid for the project roughly four times over.
The SEO part, honestly. Indexed URLs in Search Console rose from 71,000 to 96,000 over ten weeks, which I attribute mostly to crawl budget — the crawl stats report shows average response time dropping from 780ms to 240ms in the same window, and pages crawled per day roughly doubling. Organic sessions rose about 9% over the quarter. I would not claim all of that. They also published forty category descriptions in the same period, and I have no clean way to separate the two. Anyone who tells you they can attribute a 9% traffic change to a caching project with confidence is doing marketing, not measurement.
18. Common Mistakes, In The Order I Encounter Them
Assuming the CDN caches by default. Most providers cache static extensions out of the box and nothing else. HTML is opt-in everywhere. If you have never explicitly configured HTML caching, you do not have it.
Judging by the overall hit rate. Covered above, and it is the single most common reason a broken configuration survives for years.
Caching a redirect. A 301 cached at the edge with a long TTL is very hard to take back. Use 302 while you are still deciding.
Caching error pages. Some CDNs cache a 404 or a 500 with a default TTL. A transient origin error that gets cached for an hour turns a blip into an outage. Set a short negative TTL explicitly — 10 to 30 seconds is plenty.
Forgetting the API. Storefronts increasingly load their content over GraphQL or REST after first paint. Those responses go through the same edge and they are almost always uncached, because POST is not cacheable and everyone sends GraphQL over POST. Persisted queries over GET are the fix and they are worth the effort.
Not testing from where customers are. A UK team testing from a UK office will never see the problem the Australian customers have.
19. Questions People Ask
"We're on Shopify. Does any of this apply?" Partly. Shopify runs its own edge and caches your storefront HTML for you, and you cannot set Cache-Control on it. What you still control: how many third-party scripts and apps you load, whether your images go through their CDN with sensible transforms, and whether your app blocks make uncached AJAX calls on every page load. The last one is where I find most Shopify performance problems.
"Will a CDN fix my slow TTFB?" Only the parts of it that are network latency and only for requests that hit cache. If your origin takes 900ms to build a page and you have a 0% HTML hit rate, a CDN changes your TTFB by roughly the distance saving and nothing else. Measure your origin's own response time first. If it is above 400ms with a warm application cache, fix that before buying edge capacity.
"How long should I cache HTML for?" Longer than you are comfortable with, provided you have tag-based purging. An hour of s-maxage with a day of stale-while-revalidate and reliable invalidation beats sixty seconds with no invalidation, both for hit rate and for how it behaves when your origin has a bad afternoon.
"Does caching hurt personalisation?" It constrains it, and that constraint is usually healthy. Most storefront personalisation is a name in a header and a cart count, and both belong in a separate request. If your personalisation is genuinely deep — different product sets per visitor — you need edge compute or you need to accept a low hit rate on those templates. Pick one deliberately rather than discovering it.
"Can I cache logged-in pages?" Not in a shared cache, no. You can cache them in the browser with private, and you can cache the expensive fragments server-side. The edge is the wrong layer for authenticated content and every attempt I have seen to make it work has ended with someone seeing someone else's data.
"Is Cloudflare's free plan enough?" For a small store, genuinely yes. You get the network, TLS, HTTP/3 and basic caching. What you do not get is cache tags, which means purging is by URL or everything, and that is the constraint that will eventually push you up a tier. Do not upgrade until you feel that specific pain.
"Should I cache at the origin as well?" Yes, and people skip this. Varnish or a full-page cache in front of your application handles the misses that reach origin, so a cold edge is not a cold application. Belt and braces, and the origin cache is also what protects you during a purge-all.
"How do I stop marketing from breaking this?" Give them a purge button. Genuinely — a small internal tool with a "clear cache for this page" action, scoped to safe paths, removes the entire class of "the change isn't showing" escalation and the accompanying pressure to lower TTLs. It took an afternoon to build and I would do it first next time.
"What about HTTP/3?" Enable it, it is free on every major provider, and the gains are real for lossy mobile connections specifically. It is not a substitute for caching. The detail on QUIC is worth reading if you are curious, but do not sequence it before your cache policy.
20. What I'd Do First
In this order, on a store I had never seen before.
Run two consecutive curl requests against a product page and read the cache status header. Thirty seconds of work and it tells you whether the rest of this article is urgent or academic. If the second request reports a miss, everything below is worth doing.
Pull the hit rate split by content type rather than the headline figure. Find the HTML number. Write it down somewhere your team can see it.
Read your origin's response headers on a product page and find out what is preventing caching. It is nearly always Vary: Cookie, a Set-Cookie, or a no-store somebody added for a good reason four years ago.
Before changing anything, write the bypass rules for checkout, cart and account, and put the CI check in place that verifies them. Do this first, not last, so that every subsequent change is happening inside a guard rail.
Then audit what actually reads from the session on your cacheable templates. Every one of them. This is tedious and it is the step I skipped and regretted, and it is what stands between you and a trade customer seeing consumer prices.
Normalise the cache key. Query parameter allowlist, cookie allowlist, encoding normalisation. This is often the single largest hit rate improvement available and it takes an afternoon.
Set stale-if-error on everything anonymous, today, regardless of what else you do. It costs nothing and it is the cheapest availability insurance available to you.
Build tag-based purging before you extend TTLs, not after. I have made the opposite mistake in production and it cost a client the first hour of a flash sale.
Then extend the TTLs, and keep extending them until something breaks or someone complains. The right TTL is not a number you can derive; it is one you find by pushing until you meet a constraint.
And measure it in the field, at the 75th percentile, split by country. If the number does not move for real customers, the change did not happen, whatever the dashboard says.
Suggested & Related Reading
Explore related deep engineering and architectural guides from Kenneth D'Silva:
-
Performance Optimization for Magento & Shopify: The Engineering Blueprint
In-depth architectural analysis of Varnish VCL proxy layers and high-throughput Redis session offloading.
-
Technical SEO Audits & Aggressive Crawl Budget Allocation
Advanced engineering techniques to maximize critical search indexing efficiency for extensive product catalogs.
-
Mastering Google Core Web Vitals (LCP, INP, CLS)
Learn to accurately measure, deeply diagnose, and surgically repair failing field data metrics using distributed computing.