Browser caching means the visitor's browser keeps a copy of your files (stylesheets, scripts, fonts, images) and reuses it on the next page view instead of downloading it again. The browser cache is where those copies live: a storage area on the visitor's own device, in memory and on disk. Whether it does, and for how long, is decided by one response header, Cache-Control, which on most sites is either missing or set the same way for every file. Set it right and a returning visitor downloads only the HTML; set it wrong and they fetch the same 300 KB of JavaScript on every page. It is one of the delivery settings covered in the pillar guide, HTTP/2 and HTTP/3 explained.
This guide explains the header in plain terms, gives the three policies that cover every file on a normal site, shows where to set them, and how to change a cached file without waiting a year for it to expire. Allow 30 minutes plus testing.
Quick answer
| File | Policy | Header |
|---|---|---|
| HTML pages | Always check for a new version | Cache-Control: no-cache (or max-age=300 if a 5-minute delay is fine) |
| CSS, JS, fonts and images with a version or hash in the URL | Keep for a year, never check | Cache-Control: public, max-age=31536000, immutable |
Static files without a version (/logo.png, /favicon.ico) | Keep briefly, then check | Cache-Control: public, max-age=86400 plus the server's ETag or Last-Modified |
- Change a long-cached file by changing its URL (
app.3f9a1c.cssorstyle.css?ver=2.1), never by editing it in place. - Set it once, at the web server or CDN, by file type or folder.
- Check with the HTTP/2 test or
curl -Ion one file of each kind.
What is the browser cache?
The browser cache is a folder of stored responses that every browser keeps on the device, plus a smaller, faster cache in memory for files used by pages that are open. When a page asks for a file, the browser looks there first. If it holds a copy that is still fresh according to the file's Cache-Control header, it uses it and sends no request at all. In Chrome's developer tools, those files show "(memory cache)" or "(disk cache)" in the Network panel instead of a size.
Three things about it matter to a site owner:
- You decide how long each file stays fresh, not the visitor. The server sends the lifetime with every file; the rest of this guide is about choosing it.
- Each site has its own cache. Since Chrome 86 (2020), and in Safari and Firefox as well, the cache is partitioned by the site that loaded the file, so a library from a public CDN that another site already loaded is downloaded again for yours (Chrome's notes on HTTP cache partitioning).
- Visitors can clear it, and browsers evict old files when space runs low. Ctrl+Shift+Delete (Cmd+Shift+Delete on a Mac) opens the clear-data dialog in Chrome, Edge and Firefox. A cache policy speeds up the visitors who still have the files; it is never a guarantee.
Why browser caching matters
Without a cache lifetime, the browser has to ask the server about every file on every page view. At best it gets a short "not modified" answer, which still costs a round trip per file; at worst it downloads the file again. On a page with 60 files that is 60 requests a returning visitor did not need, each waiting for the network on a phone. With the right header, those files load from the device in 0 ms and only the HTML travels.
Caching also protects your server: every file served from a browser or CDN cache is a request your origin never sees. "Leverage browser caching" was the name of the old PageSpeed advice for exactly this; Lighthouse later called it "Serve static assets with an efficient cache policy", and in Lighthouse 13 it became the "Use efficient cache lifetimes" insight. All three mean the same fix.
The catch is that caching is a promise. max-age=31536000 tells the browser "this file will not change for a year". Edit the file at the same URL and visitors keep the old version. That is why the policy depends on whether the URL changes when the content does.
The Cache-Control directives in plain words
max-age=N: the file is fresh for N seconds after it arrived. While fresh, the browser uses its copy without asking.s-maxage=N: the same, but only for shared caches such as a CDN, so the edge can keep a page longer than browsers do.public: any cache may store it.private: only the visitor's browser may; use it for pages with personal content.no-cache: store it, but check with the server before every use. Despite the name, it caches.no-store: do not store it anywhere. For banking pages and personal data, not for your logo.must-revalidate: once stale, never use the copy without checking, even if the server is unreachable.immutable: the file will not change while fresh, so do not check even when the visitor presses reload. Firefox and Safari honour it; Chrome already skips that check for page resources.stale-while-revalidate=N: after expiry, use the old copy for up to N more seconds while fetching a new one in the background. Supported by Chromium browsers, Firefox and many CDNs.
The older Expires header gives a fixed date instead of a lifetime; when both are present, max-age wins.
When a copy is stale or marked no-cache, the browser sends the ETag it received as If-None-Match, or the Last-Modified date as If-Modified-Since, and the server answers 304 Not Modified with no body if nothing changed. That is the cheap check behind no-cache on HTML; nginx, Apache and Caddy send both headers for static files by default.
A file with neither Cache-Control nor Expires is not "not cached". Browsers guess a lifetime, typically a tenth of the time since Last-Modified (the heuristic RFC 9111 suggests), which is unpredictable and differs between browsers. MDN's HTTP caching guide explains the rules in full.
How getReport checks it
The HTTP/2 test, and every full report, loads the page in Chromium and records the response headers of every file.

This one counts the scripts, stylesheets, images and fonts the page loaded, yours and third-party. A file counts as cacheable with a positive max-age or s-maxage, immutable, or an Expires date in the future; no-store and a bare no-cache do not count. The finding appears when more than 20 % of the files have no lifetime, and lists up to ten of them with the header they sent.
This one comes from Lighthouse, in the Speed module. It also flags lifetimes that exist but are short (an hour on a stylesheet that never changes) and estimates the bytes a returning visitor could have loaded from cache. The cache TTL learn page covers the Lighthouse side.
The CDN costs no points; it is listed because the CDN is usually where cache headers, Brotli compression and the protocol are set.
How to set up browser caching
1. Sort your files into the three groups
For each file in the finding, ask one question: does its URL change when its content changes?
app.3f9a1c.css,/_next/static/…,/assets/index-B7kq2.js: a hash in the name. A year.style.css?ver=6.8.2: a version in the query string. A year in most setups (step 4)./logo.png,/favicon.ico: the same URL forever. A day plus validation, unless you rename the file whenever it changes.- The HTML page itself:
no-cache.
Build tools (Vite, webpack, Next.js, Astro) hash file names by default, which is why their output can safely be cached for a year.
2. Set the headers on the server
nginx, in the server block:
# Hashed build output: a year, never revalidated
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Other static files: CSS/JS with ?ver=, fonts, images
location ~* \.(?:css|js|mjs|woff2?|png|jpe?g|gif|webp|avif|svg|ico)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# HTML: always revalidate (ETag/Last-Modified make that cheap)
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}Adapt the last try_files to your application (WordPress uses /index.php?$args). One nginx rule catches almost everyone once: an add_header inside a location replaces all add_header lines from the server level, so security headers disappear from those files unless you repeat them (or include a shared snippet in each block). Test with nginx -t, then reload.
Apache, in .htaccess or the virtual host, with mod_headers enabled:
<IfModule mod_headers.c>
# Versioned CSS/JS, fonts and images: a year, never revalidated
<FilesMatch "\.(css|js|mjs|woff2?|png|jpe?g|gif|webp|avif|svg|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# Static HTML files: always revalidate
<FilesMatch "\.html?$">
Header set Cache-Control "no-cache"
</FilesMatch>
</IfModule>If only mod_expires is available, ExpiresActive On with lines such as ExpiresByType text/css "access plus 1 year" sets the lifetime by type (without immutable).
Caddy, in the Caddyfile, with named matchers so the rules never overlap:
example.com {
root * /var/www/example
@static path *.css *.js *.mjs *.woff2 *.png *.jpg *.jpeg *.webp *.avif *.svg *.ico
header @static Cache-Control "public, max-age=31536000, immutable"
@html path *.html */
header @html Cache-Control "no-cache"
file_server
}3. Set the CDN
A CDN has two lifetimes: how long it keeps the file at the edge, and what it tells browsers. On Cloudflare, static file types are cached at the edge by default and HTML is not. Set Browser Cache TTL to "Respect existing headers" so your origin's Cache-Control passes through unchanged. To cache HTML at the edge as well, add a Cache Rule or send s-maxage from the origin, and purge when you publish. If you version files with ?ver=, keep the standard caching level so a new version is a new cache entry. What a CDN is and what it fixes covers edge caching and bypass rules.
4. Bust the cache by changing the URL
When a year-cached file changes, give it a new URL. Every visitor fetches the new one on their next page view, because the HTML (which is revalidated) now points at it.
- File name hash (
app.3f9a1c.css): the build tool does it, and every cache treats it as a new file. The most reliable option. - Query string (
style.css?ver=2.1): what WordPress does for theme and plugin files. It works in browsers and most CDNs, but a cache set to ignore query strings serves the old file.
Never edit a year-cached file in place and hope: visitors who have it keep it until the lifetime runs out.
Platform notes
- WordPress: page caching plugins store rendered HTML on the server, which speeds up the HTML but does not set browser lifetimes for CSS, JS and images; some have a separate "browser cache" setting. On Apache the plugin can write rules into
.htaccess; on nginx the host has to add the rules from step 2. Theme and plugin files load asstyle.css?ver=…, so a year is safe; avoid "speed tips" that strip?ver=, or visitors keep old CSS after updates. - Shopify serves theme files and images from its own CDN with its own headers. Listed files are usually third-party app scripts.
- Static sites and frameworks: Next.js marks
/_next/static/as immutable automatically. On Netlify set headers in_headers, on Vercel invercel.json. The HTML policy is the one to check.
How to verify
- Run the HTTP/2 test again: the cache finding reads "Static files have a cache lifetime", or lists only third-party files.
- Check one file of each group from a terminal:
curl -sI "https://www.example.com/wp-content/themes/mytheme/style.css?ver=2.1" | grep -iE "cache-control|etag|last-modified|age"
curl -sI https://www.example.com/ | grep -iE "cache-control|etag"- In the browser's developer tools, Network tab: open a page, then click to another page on the site. Stylesheets, scripts and fonts should show "(memory cache)" or "(disk cache)" in the Size column.
Common mistakes
- A long
max-ageon HTML that changes. You publish and visitors still see the old page for hours. HTML getsno-cacheor a few minutes; cache it longer only at the CDN, with a purge on publish. no-storeon everything. Often a security plugin or framework default, and every visit downloads everything. Keepno-storefor personal data.Vary: *. It makes the response uncacheable.Vary: Accept-Encodingis normal; anything broader needs a reason.- Forgetting fonts. Rules written before
.woff2existed give fonts no lifetime. They never change; give them a year. - Security headers vanishing after a new nginx cache rule. The
add_headerinheritance rule above; security headers from zero has the full set to repeat.
Questions people ask
What is browser caching?
Browser caching is the browser keeping copies of a website's files on the visitor's device and reusing them instead of downloading them again. The server decides how long each file may be reused with the Cache-Control header. Stylesheets, scripts, fonts and images are the files that benefit most, because they rarely change and appear on every page, so returning visitors load pages much faster.
What does "leverage browser caching" mean?
It is older PageSpeed advice meaning "give your static files a cache lifetime". The report flags CSS, JavaScript, fonts and images that are sent without a Cache-Control: max-age or with a very short one, so returning visitors download them again. The fix is to set a long lifetime (a year for versioned files) at the web server or CDN, as in the configs above.
How long should I cache static files?
A year (max-age=31536000) for files whose URL changes when their content does: hashed build files and files with a version parameter such as ?ver=2.1. For files that keep the same URL when edited, such as a logo, use about a day plus ETag validation. HTML should be revalidated on every visit with no-cache, or cached for a few minutes at most.
Does browser caching help first-time visitors?
No. Browser caching only helps from the second page view onwards, because the browser has to download each file once before it can keep a copy. For first-time visitors, the gains come from compression, a CDN near them and smaller files. Most visits include several page views, though, so a good cache policy still speeds up most of the pages people see.
How do I clear the browser cache for my visitors?
You cannot clear it remotely, so change the URL instead. A new file name or version parameter (style.css?ver=2.2) is a new file to the browser, which downloads it as soon as the HTML points at it. That is why HTML itself should be revalidated on every visit: it is the one file that has to be fresh for URL changes to reach visitors.