A CDN is a cache with global PoPs, and almost every CDN problem is a cache-key or a cache-header problem rather than a vendor problem. These are the ones that come up repeatedly.
The browser and the CDN do not need the same TTL. s-maxage applies only to shared caches, so you can hold something at the edge for an hour while letting browsers revalidate every minute.
# hashed asset, never changes
Cache-Control: public, max-age=31536000, immutable
# HTML that changes but tolerates a stale second
Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400
# genuinely private
Cache-Control: private, no-store
stale-while-revalidate is the highest-value directive most sites are not using: the edge serves the stale copy instantly and refreshes in the background, so an origin that is slow or briefly down does not become a user-visible outage. Pair it with stale-if-error.
no-cache means "store it, but revalidate before use". no-store means "do not write it to disk anywhere". Marking authenticated pages no-cache and assuming they are private is a genuine data-leak pattern. If a response contains one user's data, it needs private, no-store.
Vary: Accept-Encoding is fine — a handful of variants. Vary: User-Agent is catastrophic: there are effectively unlimited user-agent strings, so every visitor gets a cache miss and your hit rate collapses to near zero while your origin bill does not. If you need device-based variation, use a small normalised header your edge sets (mobile/desktop/tablet) and vary on that instead.
The same applies to query strings. By default many CDNs include the full query string in the cache key, so ?utm_source=twitter and ?utm_source=email are separate objects for identical content. Configure the CDN to ignore or allow-list query parameters — on a site with heavy campaign tagging this single change can move hit rate by tens of points.
Content-hashed filenames (app.9f2c1b.js) mean you never purge: the new deploy references a new URL, and the old one can stay cached forever. Purging is inherently racy — propagation across PoPs takes seconds to minutes, and during that window different users get different versions. Reserve purge for mistakes and for HTML.
curl -sSI https://example.com/app.css | grep -Ei 'cache|age|cf-|x-cache|vary'
Age tells you how long the edge has held the object; if it resets to 0 on every request you are missing. X-Cache or the vendor's equivalent gives HIT/MISS. Test from more than one location — a HIT in your nearest PoP says nothing about the one serving your actual users.
Pricing models differ enough to matter. Cloudflare's standard plans do not meter bandwidth for web content, which makes it the default for static sites. Bunny bills per GB with regional tiers starting around a cent per GB. Fastly and CloudFront bill per GB with the price falling in tiers, plus per-request charges, and CloudFront's egress from origin to edge is billed separately from edge to user. For a site serving mostly small files, request charges can exceed bandwidth charges — check both columns before assuming per-GB price is the number that matters.
Serve Brotli where the client accepts it and gzip otherwise; most CDNs will do this for you if the origin sends uncompressed responses with the right content type. Do not double-compress already-compressed formats — JPEG, WebP, WOFF2, MP4 — you spend CPU to add bytes. Set Brotli quality around 4–6 for dynamic responses and 11 for static assets compressed at build time.
A 301 cached at the edge for a year is very hard to take back. A 500 cached for ten minutes turns a transient blip into a sustained outage. Set explicit short TTLs for error responses (most CDNs default to something small, but verify) and use 302/307 while you are still deciding what a redirect should do.