The CDN is enabled. The origin sends sensible browser caching headers. A lab run on the priority URL still shows a slow document Time to First Byte and repeat views that behave like cold loads. In our experience the gap is rarely “caching is off” as a single switch. It is a mismatch between what the origin allows, what the CDN dashboard overrides, and which layer the browser actually reuses on the next navigation. Cache-Control is the contract that ties those layers together. When the contract is vague or copied from a generic template, performance work does not stick past the next platform default or purge.
What follows is a content-type guide for teams who already understand DNS, TLS, and edge routing from Network Performance for Web Teams: DNS, TLS, HTTP, CDN, and Cache Rules. That piece explains where cache hits change TTFB on the request path. Here we focus on the header strings that make those hits legal, the CDN defaults that undo them, and how to prove the policy held after deploy with scheduled lab runs.
What Cache-Control changes on the path to first byte and repeat views
The Cache-Control response header holds directives that tell browsers and shared caches (CDNs, reverse proxies) whether they may store a response, how long it stays fresh, and whether they must revalidate before reuse. Freshness is measured from when the response was generated at the origin, adjusted by any Age header added by intermediaries. A directive on the HTML document can therefore change first-byte wait on a CDN miss, while directives on CSS, JavaScript, fonts, and images change how much work repeats on the second view without touching the document request at all.
The directives that matter most for web performance work are max-age (fresh lifetime in seconds), s-maxage (fresh lifetime for shared caches only), public and private (whether a shared cache may store the response), no-cache (store but revalidate before use), no-store (do not store), immutable (while fresh, skip revalidation on reload), must-revalidate, and extension directives such as stale-while-revalidate and stale-if-error. MDN documents each directive; RFC 9111 is the normative reference when you need to settle a dispute between engineering and the CDN vendor.
Treat every response as belonging to a content class before you copy a header string from a blog post. HTML entry points, fingerprinted static bundles, unversioned images, authenticated account pages, and public JSON APIs need different policies. The performance win comes from aligning the class, the origin header, the CDN rule, and your purge discipline so they still agree on the live hostname after release.
Browser cache versus CDN cache: two layers that need different directives
Browser caching headers govern the copy on the device. CDN cache rules govern the copy at the edge. They read the same Cache-Control line but apply different parts of it. s-maxage applies only to shared caches and overrides max-age for them when both are present. private keeps a response out of shared caches while still allowing a browser store. immutable affects reload behaviour for static assets in the browser. Confusing the two layers produces tickets that say “we purged the CDN but nothing changed,” because the team purged the edge while the browser still held an old document, or vice versa.
| Layer | Who stores | Typical levers | Effect on cold TTFB | Effect on repeat views / LCP |
|---|---|---|---|---|
| Browser | Returning visitor |
max-age, immutable, no-cache + validators |
Small on first visit | Large when static assets skip full downloads |
| CDN (shared cache) | Any visitor hitting that PoP |
s-maxage, public, origin cacheability, purge tags |
Large on cache hit for HTML and assets | Smaller origin load; stable edge latency |
| Origin | Source of truth | App framework defaults, CDN-Cache-Control on some stacks |
Defines miss cost | N/A until next request |
A useful policy document names the content class, the origin header, the CDN behaviour when the header is absent, and who purges what on deploy. Without that table, agencies inherit “cache everything” dashboard templates that fight the application’s HTML strategy or strip ETag headers the framework relies on for cheap 304 Not Modified responses.
Cache-Control for fingerprinted CSS, JavaScript, fonts, and images
Static assets whose URLs change when content changes should be cached aggressively at both browser and CDN. The filename is the versioning mechanism. A stylesheet served as /assets/app.9f1c2d4e.css can safely carry a one-year freshness lifetime because the next deploy publishes /assets/app.b3e8a1f0.css and the HTML entry point references the new name.
Recommended pattern for hashed or fingerprinted files:
Cache-Control: public, max-age=31536000, immutable
The immutable directive, defined in RFC 8246, tells clients not to send conditional revalidation requests while the response is fresh. That matters on hard reloads: without immutable, browsers may still ask the server whether app.9f1c2d4e.css changed even though the URL encodes the revision. For performance-sensitive sites, those avoidable round trips show up as extra connection work before Largest Contentful Paint can use a cached font or script.
Do not put immutable on unversioned paths such as /styles/main.css or /logo.png unless your release process never overwrites the same URL. If the URL is stable, use a shorter max-age plus ETag or Last-Modified validators so updates propagate without waiting a year. Google’s guidance on efficient cache lifetimes aligns with the fingerprinted-file rule: long TTLs belong where the URL changes with the bytes.
After you set long TTLs at the origin, confirm the CDN is allowed to store those responses. Some platforms require public explicitly. Some honour a separate CDN-Cache-Control or vendor-specific override. Inspect Age, CF-Cache-Status, X-Cache, or the equivalent on a CSS or font request in production, not only on HTML.
Cache-Control for HTML documents agencies actually ship
HTML is the map to every other asset. If the browser or CDN serves stale HTML, it may point at old bundle names even when the new files already sit on the origin. Most delivery teams therefore want HTML to revalidate often while still allowing the CDN to absorb traffic for anonymous pages.
Common patterns:
Revalidate every use (good default for many marketing sites):
Cache-Control: no-cache
ETag: "build-20260910-abc123"
no-cache does not mean “do not cache.” It means the cache must validate with the origin before reuse. With a matching ETag, validation is a small 304 Not Modified instead of a full document download. That keeps HTML current without abandoning bandwidth savings.
Short browser freshness, longer edge TTL, background refresh:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300, stale-if-error=600
Browsers treat the document as stale immediately and revalidate. The CDN may serve a fresh copy for sixty seconds and continue serving stale responses briefly while it revalidates in the background. This pattern can lower origin load during traffic spikes if your content model tolerates short edge staleness on public pages.
Personalised or authenticated HTML:
Cache-Control: private, no-store
or, when the response is user-specific but you still want browser revalidation without CDN storage:
Cache-Control: private, no-cache
Never mark personalised HTML public at the CDN unless you have deliberate edge logic that varies the cache key safely. Vary: Cookie without a tight vary set often collapses hit ratio to zero. Wrong HTML caching creates correctness incidents that outweigh any TTFB improvement.
When you deploy new HTML that references new asset hashes, purge or revalidate HTML at the CDN if your policy allows edge caching at all. Serving cached HTML that still points at removed bundles is a common post-release failure mode that looks like a performance regression in Lighthouse even though the new files are fast.
CDN defaults and dashboard rules that undo your header policy
Agencies often enable a CDN before origin headers are deliberate. Vendors ship attractive defaults: cache all static extensions for a month, “standard” caching for HTML, ignore certain origin headers, or respect only cache rules defined in the dashboard. Those defaults are a starting point, not a performance policy.
Defaults we see cause repeat TTFB and LCP pain:
| Mistake | What breaks | What to check on the live URL |
|---|---|---|
CDN caches HTML with a long TTL while origin says no-cache
|
Stale pages, wrong asset references after deploy |
CF-Cache-Status / X-Cache on / and a template URL |
Origin sends Cache-Control: private but team expects edge hits |
Every request becomes an origin fetch | Response headers on document request |
“Cache static file types” rule bypasses immutable fingerprint policy |
Unnecessary revalidation or wrong TTL per extension | Headers on .js, .css, and font files |
| Blanket purge on every CMS save | Hit ratio collapses; TTFB spikes for a day |
Age header trend across scheduled runs |
Vary: Cookie on anonymous pages |
Edge refuses to share entries | Cache key documentation from vendor |
| Flexible SSL or redirect loops at edge | TTFB rises before cache policy matters | Redirect count in waterfall (see post 62) |
Dashboard rules should echo origin intent, not fight it. If the application sets CDN-Cache-Control or vendor-specific surrogate headers, document which header wins when both are present. After any CDN migration, re-audit the same priority URLs you used for the previous vendor. Rules do not transfer verbatim between Cloudflare, Fastly, Akamai, and CloudFront.
How Cache-Control headers show up in TTFB and LCP lab runs
Time to First Byte in PageSpeed Insights and Lighthouse reflects how long the navigation request waited for the first byte of the HTML response. When the CDN serves a fresh document from cache, TTFB often drops sharply compared with an origin miss. When HTML is private, no-store or bypasses the edge, TTFB still includes origin compute and PoP-to-origin latency even if static assets are well cached.
Largest Contentful Paint inherits that document delay and then adds how quickly the LCP resource itself arrives. A hero image with a long max-age at the CDN may paint fast on repeat views while the first view still waits on HTML generation. Conversely, perfect HTML caching cannot fix LCP if the LCP image lives at an unversioned URL with a five-minute TTL and the CDN misses every time.
Practical reading order in lab tools:
- Document request: TTFB, cache status headers, redirect count.
- LCP element request: cache hit or miss, protocol, priority hints.
- Render-blocking CSS and font requests: are they fingerprinted with long TTLs or re-fetching each view?
Pair a cold run with a repeat view when your tool supports it. Cold exposes DNS, TLS, and first miss behaviour. Repeat exposes whether browser caching headers actually removed duplicate downloads. For operational thresholds on the same URLs, set performance budget thresholds that include TTFB and LCP, not only the Performance score.
Once first byte and paint are acceptable, the LCP work in A Quick Way to Fix LCP: Four Changes That Cut Time to Paint still matters. Cache-Control removes avoidable network round trips; it does not shrink an oversized hero file or late-discovered LCP candidate.
Verify Cache-Control on live URLs after every deploy
Header policy is only real on the public hostname users hit. Staging often lacks the CDN overlay or sends different headers from the production origin. After each deploy, cache rule edit, or CMS plugin that touches headers, verify the live response rather than assuming the dashboard screenshot still matches production.
Checklist we use on client sites:
- Document URL: homepage plus one high-intent template (product, pricing, article).
- Representative static asset: hashed JS or CSS referenced by that HTML.
- LCP resource: image or video request from the same lab run.
-
Cache status:
Age, vendor hit/miss header, and the fullCache-Controlline. -
Validators:
ETagorLast-Modifiedpresent where you expect revalidation. - Compare to policy doc: content class, intended header, CDN rule, purge owner.
Export the header set into the ticket or client report so the next engineer does not re-discover the same miss. A single manual check catches the first mistake. It does not prove the CDN still hits next Thursday after a content editor triggers a purge storm.
Put priority URLs on scheduled PageSpeed monitoring with device splits that match the audience. Apogee Watcher stores lab payloads over time so you can see TTFB and LCP drift after edge changes without someone opening PageSpeed Insights from memory. That layers onto your CDN vendor; it does not replace header configuration or purge discipline. For why one-off lab runs are insufficient proof, see PageSpeed Insights vs Automated Monitoring: When Manual Checks Aren't Enough.
FAQ
What is the difference between no-cache and no-store?
no-cache allows storage but requires validation before reuse. Browsers and CDNs may keep a copy and often answer with 304 Not Modified when the ETag still matches. no-store forbids storing the response anywhere. Use no-cache for HTML that must stay current but can revalidate cheaply. Use no-store for sensitive account or payment flows where persistence is unacceptable.
When should we use immutable?
Use immutable only on fingerprinted static assets where the URL changes when the bytes change. Pair it with a long max-age, commonly 31536000 seconds. Do not use it on HTML or on unversioned files you overwrite in place.
Does max-age=0 disable caching?
No. It marks the response as immediately stale, which forces revalidation behaviour similar in practice to no-cache for many caches, but the directives are not identical in every intermediary. Be explicit: choose no-cache when you want validation semantics, or no-store when you want no persistence.
Why did TTFB improve in WebPageTest but not in PageSpeed Insights?
Tools use different locations, cache states, and throttling. A hit from one PoP near the test agent does not prove global behaviour. Align test location with audience geography and inspect cache headers on the document request in both tools before closing the ticket.
Should APIs use the same Cache-Control as HTML?
Public read-heavy JSON can use short browser max-age with longer s-maxage and stale-while-revalidate when responses are identical for all users. Authenticated or user-specific APIs usually need private or no-store. Treat each endpoint as its own content class.
We purged the CDN. Why do clients still see old CSS?
Purging the edge does not clear browser caches. If CSS URLs are not fingerprinted, visitors may keep an old file until max-age expires. Prefer hashed filenames for bundles and purge HTML if it still references retired names.
Put header policy on the same schedule as performance budgets
Pick one priority URL where TTFB or LCP still misses budget. Capture the live Cache-Control lines on HTML, the LCP asset, and one fingerprinted bundle. Fix the largest layer mismatch first: origin header, CDN rule, or purge process. Re-run lab from a region that matches traffic, then add the URL to a weekly schedule so the next header regression is visible before the client forwards a screenshot.
Start a free trial or run a free PageSpeed check on the URLs you care about, then keep the same pages on a portfolio schedule after you fix Cache-Control for web performance.
References
- Cache-Control header (MDN) (Mozilla)
- RFC 9111: HTTP Caching (IETF)
- RFC 8246: HTTP Immutable Responses (IETF)
- Uses efficient cache lifetimes on static assets (web.dev)
- Network Performance for Web Teams: DNS, TLS, HTTP, CDN, and Cache Rules (Apogee Watcher)
- Performance Budget Thresholds Template (Apogee Watcher)
- A Quick Way to Fix LCP: Four Changes That Cut Time to Paint (Apogee Watcher)
- How to Schedule PageSpeed Monitoring: Test Frequency and Priority for Your Portfolio (Apogee Watcher)
- PageSpeed Insights vs Automated Monitoring: When Manual Checks Aren't Enough (Apogee Watcher)
Top comments (0)