How to Cache Static Content (and Dynamic Pages Too): Headers, CDN and Rules That Work
"Serve static assets with an efficient cache policy" is the Lighthouse warning almost every site gets, and it is the cheapest performance fix there is: a few response headers, set once, and every repeat visit stops re-downloading files that have not changed. Caching HTML is harder, because pages change and some are personal, but the same headers and a CDN handle that too. This guide gives the exact values to use, explains what each one does, shows how the browser, CDN and server caches fit together, and includes configs for Nginx, Apache, Next.js and WordPress.
The three caches
Every request can be answered from one of three places, and each is controlled by the same header:
- The browser cache. Files the visitor already downloaded. Controlled by
Cache-Control: max-age. Speeds up repeat visits and navigation within the site. - The CDN or edge cache. Copies kept on servers near visitors. Controlled by
Cache-Control: s-maxage(ormax-agewhens-maxageis absent), plus CDN-specific overrides. Speeds up first visits and protects the origin. - The server cache. A page cache (the generated HTML) or object cache (database query results) on your own server. Controlled by your CMS or framework, not by headers. Speeds up cache misses and lowers Time to First Byte.
A fast site uses all three: assets cached in the browser for a year, pages cached at the edge for minutes, and a server cache so the origin can regenerate quickly when asked.
Cache-Control, directive by directive
| Directive | Meaning |
|---|---|
max-age=N | The browser (and CDN, unless s-maxage is set) may reuse the response for N seconds without asking. |
s-maxage=N | Same, but only for shared caches (CDNs, proxies); overrides max-age there. |
public | Shared caches may store it even if the request had authorization. |
private | Only the browser may store it; CDNs must not. Use for anything personal. |
no-cache | Store it, but revalidate with the server before every reuse (a poorly named directive; it does not mean "don't cache"). |
no-store | Never store it anywhere. For sensitive responses and anything that must always be fresh. |
must-revalidate | Once stale, do not serve it without checking back, even on error. |
immutable | The file will never change at this URL; browsers skip revalidation even on reload. |
stale-while-revalidate=N | After expiry, serve the stale copy for up to N seconds while fetching a fresh one in the background. |
stale-if-error=N | Serve the stale copy if the origin errors, for up to N seconds. |
The two that do the most work are max-age with immutable for versioned files, and s-maxage with stale-while-revalidate for pages.
Static assets: cache for a year, change the filename
The rule that makes long caching safe: every asset that can change gets a fingerprint in its filename, so a new version is a new URL and the old cached copy is simply never requested again.
main.3f9a1c.js
styles.b71e0d.css
hero.a81b.webp
Modern build tools do this by default (Next.js under /_next/static/, Vite, webpack with [contenthash], Rails and Django asset pipelines). Then the header can be maximal:
Cache-Control: public, max-age=31536000, immutable
One year, and immutable tells the browser not to bother revalidating even when the user hits reload.
Files that are not versioned (/logo.png, /favicon.ico, files uploaded through a CMS) cannot get a year, or a replaced image will stay stale for visitors who saw the old one. Give them a day or a week and let validation handle the rest:
Cache-Control: public, max-age=86400
Fonts are versioned by their build hash or by a version in the path and get the one-year rule; add crossorigin on the preload and the CDN must send CORS headers for them.
Validation: ETag and Last-Modified
When a cached copy expires, the browser can ask "has this changed?" instead of downloading it again. It sends If-None-Match with the ETag it stored (or If-Modified-Since with the date), and the server replies 304 Not Modified with no body if the file is the same.
That saves bandwidth but not the round trip, which is why validation is the fallback, not the strategy: for versioned assets, immutable avoids the request entirely; for unversioned files, ETags make a short max-age cheap. Most servers send ETags automatically; if a load balancer or CDN strips them, Last-Modified still works.
One trap: on a multi-server setup, Apache's default ETag includes an inode number, so the same file gets different ETags on different servers and never validates. Configure the ETag to use size and modification time only, or rely on Last-Modified.
HTML and pages: cache at the edge, not in the browser
Pages change, so the browser should not keep them for long, but a CDN can safely hold a public page for a few minutes and refresh it in the background:
Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400
Read it as: the browser always checks (max-age=0), the CDN serves it for ten minutes (s-maxage=600), and after that it keeps serving the stale copy for up to a day while it fetches a new one, so visitors never wait for the origin. Publish a change and either wait ten minutes or purge the URL.
For anything personalised (logged-in pages, carts, account settings, anything with a user's name in it):
Cache-Control: private, no-store
And for pages that must be fresh but are not private (a status page, a live scoreboard):
Cache-Control: no-cache
Two things silently disable page caching at CDNs: a Set-Cookie header on the response (CDNs will not cache a response that sets a cookie, correctly) and Vary: Cookie or Vary: *. If an analytics or consent plugin sets a cookie on every page, the CDN cannot cache any of them. Move that cookie to JavaScript or strip it at the edge.
Dynamic content that changes often
Microcaching. A page or API response that changes every few seconds can still be cached for one to ten seconds. At a thousand requests a minute, a five-second cache means the origin builds the page twelve times instead of a thousand. s-maxage=5, stale-while-revalidate=30 is a common pattern for feeds and dashboards.
Cache by parts. Keep the page cacheable and load the personal bits (the account menu, the cart count) with a small fetch after load, or render them client-side. The public shell then caches for everyone.
Purge on publish. Rather than short TTLs, use long ones and purge the affected URLs when content changes: a webhook from the CMS to the CDN's purge API. WordPress caching plugins and Cloudflare, Bunny and CloudFront all support it.
Server-side page and object caches. For a CMS, a page cache (WP Rocket, LiteSpeed Cache, W3 Total Cache, or Nginx FastCGI cache) stores generated HTML so PHP and the database are not hit on every request; an object cache (Redis, Memcached) stores query results for the requests that still reach PHP. Both are about origin speed on cache misses and for logged-in users.
Service workers can cache in the browser with full control (offline pages, precached shells) and are worth it for web apps; for content sites the headers above cover the need with far less complexity.
Server configs
Nginx
# Versioned build assets
location ~* \.(?:css|js|woff2|woff|ttf|svg|png|jpg|jpeg|gif|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# Pages: the CDN may cache, the browser revalidates
location / {
add_header Cache-Control "public, max-age=0, s-maxage=600, stale-while-revalidate=86400";
try_files $uri $uri/ /index.html;
}
# Never cache personal areas
location ~ ^/(account|cart|checkout|admin|api/me) {
add_header Cache-Control "private, no-store";
}
If unversioned uploads live under /uploads/, give that location max-age=86400 instead of a year.
Apache (.htaccess)
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|woff2|woff|ttf|svg|png|jpg|jpeg|gif|webp|avif|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
<FilesMatch "\.(html|php)$">
Header set Cache-Control "public, max-age=0, s-maxage=600, stale-while-revalidate=86400"
</FilesMatch>
</IfModule>
FileETag MTime Size
Next.js
Files under /_next/static/ are fingerprinted and already served with public, max-age=31536000, immutable. Files in /public/ are served with a short, revalidating cache by default; set longer headers for versioned ones in next.config:
async headers() {
return [
{ source: '/fonts/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }] },
{ source: '/images/:path*', headers: [{ key: 'Cache-Control', value: 'public, max-age=86400' }] },
];
}
Statically generated and ISR pages get s-maxage and stale-while-revalidate headers from the framework based on the revalidate interval, which is the page-caching pattern above without writing it by hand.
WordPress
Browser caching headers usually come from the host or a caching plugin's "browser cache" option (which writes the Apache or Nginx rules above). Page caching comes from the plugin or the host (LiteSpeed, WP Rocket, or a managed host's own layer). Check that logged-in pages and the cart are excluded, that the plugin does not set a cookie on every visit, and that the CDN respects the headers rather than applying its own defaults.
CDN rules
- Cloudflare: by default caches static extensions and not HTML. A Cache Rule can cache HTML with an Edge TTL; Browser TTL set to "respect existing headers" keeps your
max-agevalues. - Bunny: enable "respect origin Cache-Control"; set a default TTL for files that send none.
- CloudFront: the CachingOptimized policy honours origin headers with a long default; use CachingDisabled for dynamic behaviours.
Whatever the CDN, the origin headers should be correct first, so the CDN only has to obey them.
How to verify
curl -sI https://example.com/pathand readcache-control,etag,ageand the CDN status header (cf-cache-status,x-cache,CDN-Cache).- Chrome DevTools, Network panel: the Size column shows "(memory cache)" or "(disk cache)" for files served locally; a 304 status shows a successful validation; a full 200 with a download on a reload means the header is wrong.
- Lighthouse / PageSpeed: the Serve static assets with an efficient cache policy audit lists every file with a short or missing cache lifetime and the potential savings.
- WebPageTest repeat view: the second run of a page should transfer only HTML and anything uncacheable.
- A free audit reports the cache-related Lighthouse findings for the homepage and key inner pages next to real-user Core Web Vitals, so you can see whether repeat-visit speed actually improved.
Common mistakes
- A year on unversioned files. The classic "we deployed but users see the old CSS". Version the filename or shorten the TTL.
no-storeeverywhere. Usually a security plugin or a framework default meant for API responses, applied to images. Check assets specifically.- Caching responses that set cookies. The CDN refuses; the page never caches. Find the plugin that sets the cookie.
Vary: User-AgentorVary: *. Splits the cache into thousands of variants or disables it.- Private pages cached publicly. One user's data shown to another. Every personalised route needs
private, no-store. Expiresin the past or a fixed date. A leftover header that overrides intentions; preferCache-Controland delete staleExpiresrules.- Broken ETags behind a load balancer. Files never validate; see the Apache note above.
Checklist
- Build assets fingerprinted and served with
public, max-age=31536000, immutable. - Unversioned assets with a day-to-week
max-ageand working ETags. - Public pages with
s-maxageplusstale-while-revalidateat the CDN; browsermax-age=0. - Personal pages
private, no-store; no cookies set on cacheable responses. - Server page cache and object cache for the CMS.
- Purge on publish wired to the CDN.
- Verified with curl and a repeat-view test; Lighthouse cache audit clear.
Frequently asked questions
What is the difference between no-cache and no-store?
no-cache stores the response but revalidates it before every use; no-store never stores it. Use no-cache for pages that must be fresh, no-store for sensitive data.
How long should static content be cached? One year for anything with a versioned filename; a day to a week for unversioned files, with ETags so repeat checks are cheap.
Does caching affect SEO? Through speed: efficient caching improves repeat-visit load time and lowers origin load, which helps Core Web Vitals and crawl efficiency. Googlebot itself mostly ignores browser cache headers.
How do I make visitors get the new version after a deploy? Change the filename (fingerprinting) so the new version is a new URL, and purge the CDN for HTML that references it. Asking users to clear their cache is not a strategy.
Should I cache API responses?
Public, read-mostly endpoints benefit from short s-maxage values and stale-while-revalidate; anything per-user is private, no-store.
Set the asset headers first (fifteen minutes, biggest win), then the page rule at the CDN, then verify with curl. The next Lighthouse run should have one warning fewer and a faster repeat view.
Check your own site
Free SEO Checker
Title tags, headings, structured data, crawlability — and the keywords you already rank for.
More from the blog
Want to see how your website scores?
Get Your Growth Score