Cache-Control and browser caching: how to make repeat visits instant

Browser caching stores copies of your files on the visitor's device so the next page view can reuse them instead of downloading them again. You control it with the Cache-Control HTTP response header: give fingerprinted CSS, JavaScript, fonts and images a one-year max-age with immutable, and serve HTML with no-cache plus an ETag so updates appear immediately. Done right, repeat visits skip most network requests and load in a fraction of the first-visit time.
How the browser cache works
Every time the browser receives a response, it reads the caching headers to decide two things: whether it may store the response, and how long the stored copy stays fresh.
- Fresh copy: the browser reuses it with no network request at all. In Chrome DevTools the Network panel shows "(memory cache)" or "(disk cache)" in the Size column. Zero bytes, zero round trips.
- Stale copy: the browser sends a conditional request asking whether the file changed. If it did not, the server answers
304 Not Modifiedwith no body. That saves the bytes, but not the round trip. - No caching headers: browsers fall back to heuristic freshness, typically 10% of the time since the
Last-Modifieddate. The result is unpredictable, so always send explicit headers.
The cache is keyed by URL (plus any headers listed in Vary), and modern browsers partition it by the top-level site. A library that another site loaded from a public CDN is therefore not reused on yours, which removes the old argument for shared CDN copies of jQuery or fonts. Self-hosting them works just as well.
Keep in mind what the browser cache cannot do: it never helps a first-time visitor. First visits depend on server response time, distance and page weight, which is where shared caches such as a CDN come in. Our CDN guide covers that side; this guide is about making the second page view nearly free.
Cache-Control directives explained
A typical header for a fingerprinted asset looks like Cache-Control: public, max-age=31536000, immutable. These are the directives that matter in practice:
- max-age=N: the response stays fresh for N seconds after it was generated.
31536000is one year, the practical maximum. - s-maxage=N: like
max-age, but only for shared caches such as CDNs and proxies, where it takes precedence. Use it to cache at the edge longer than in browsers. - no-cache: the response may be stored, but must be revalidated with the server before every reuse.
- no-store: the response must not be stored anywhere. Every visit downloads it again.
- immutable: the file will never change at this URL, so the browser should not revalidate it while fresh, even when the visitor reloads the page.
- stale-while-revalidate=N: after the response goes stale, a cache may keep serving it for N more seconds while it refreshes the copy in the background. Caches that do not support it simply ignore it.
- private: only the visitor's browser may store the response; CDNs and proxies must not. Use it for anything personalized.
- public: shared caches may store the response even in cases they otherwise would not, such as requests with an
Authorizationheader. Ordinary static files are cacheable without it, but it does no harm. - must-revalidate: once stale, the copy must not be used without a successful revalidation, even if the server is unreachable.
no-store is the directive that disables caching, and it belongs only on sensitive responses such as account pages, banking data or one-time tokens.The full definitions are in RFC 9111 (HTTP Caching), and MDN's Cache-Control reference lists browser support for each directive.
Revalidation with ETag, Last-Modified and 304 responses
Revalidation lets a browser keep a stale copy and ask the server whether it is still current. The server attaches a validator to the original response: an ETag (an opaque fingerprint of the content) and/or a Last-Modified date. On the next request the browser echoes them back in If-None-Match and If-Modified-Since, and the server answers 304 Not Modified with an empty body if nothing changed:
# First visit: full response with validators
HTTP/2 200
cache-control: no-cache
etag: "5f1c-6283a1b2"
last-modified: Fri, 02 Oct 2026 08:14:00 GMT
# Next visit: the browser asks whether the page changed
GET /pricing HTTP/2
if-none-match: "5f1c-6283a1b2"
if-modified-since: Fri, 02 Oct 2026 08:14:00 GMT
# Unchanged: headers only, no body
HTTP/2 304
etag: "5f1c-6283a1b2"
Prefer ETags: Last-Modified has one-second resolution and changes whenever a file is copied or redeployed. nginx and Apache generate ETags for static files automatically from modification time and size; if you run several servers, make sure they produce identical ETags or every revalidation turns into a full download. For dynamic HTML, many frameworks hash the response body into an ETag. The 304 then saves the transfer, but the server still renders the page, so pair it with server-side caching as described in how to reduce TTFB.
A 304 is cheap in bytes but not in time: it still costs a full round trip plus server processing. From a region 150–300 ms away, twenty revalidated assets can make a repeat visit barely faster than the first. That is why static assets should be fresh for a long time, and only HTML should revalidate on every use.
Recommended Cache-Control policy by file type
The rule behind this table is simple: if the URL changes whenever the content changes, cache it for a year as immutable; if the URL stays the same, make the cache check back with the server.
| File type | Example | Cache-Control | Why |
|---|---|---|---|
| HTML pages | /pricing | no-cache plus ETag | Deploys appear immediately; unchanged pages cost a 304 |
| Hashed CSS and JS | app.3f9a1c2b.js | public, max-age=31536000, immutable | Any change produces a new file name |
| Unhashed CSS and JS | /js/main.js | public, max-age=3600 | Limits staleness to an hour; add hashes when you can |
| Hashed images | hero.8d2e41.avif | public, max-age=31536000, immutable | Same logic as hashed CSS and JS |
| Unversioned images | /uploads/team.jpg | public, max-age=2592000 | 30 days; upload replacements under a new name |
| Fonts | inter-latin.woff2 | public, max-age=31536000, immutable | Fonts rarely change; version the file name if they do |
| Public API JSON | /api/stats | public, max-age=60, s-maxage=300, stale-while-revalidate=600 | Short browser freshness; the CDN absorbs traffic |
| Personalized API JSON | /api/me | private, no-cache | Never stored in shared caches; checked on each use |
| Sensitive data | Account pages, tokens | no-store | Must not be written to disk |
Cache busting with content hashes
Cache busting solves the obvious problem with year-long caching: how do visitors get the new version after a deploy? The answer is to put a hash of the file's content into its name, such as app.3f9a1c2b.js. When the file changes, the hash and URL change; the freshly revalidated HTML points to the new URL, and the old copy simply stops being requested. You get maximum caching and zero staleness at the same time.
Vite, webpack ([contenthash]), esbuild and Rollup do this out of the box, as do the asset pipelines in Rails and Laravel. Three details make it work reliably:
- Hash the content, not the build time. A deploy-wide version number invalidates every file on every release, even files that did not change.
- Prefer file names over query strings.
main.js?v=42works in browsers, but some proxies and CDN configurations ignore query strings in the cache key. - Keep old files online for a while. Visitors with an open tab, or a CDN still holding old HTML, will request the previous file names. Deleting them immediately leads to 404s and unstyled pages.
For CMS uploads that cannot be hashed, upload replacements under a new file name instead of overwriting the old one, so a 30-day cache never hides an update.
Configuring Cache-Control in nginx, Apache, Cloudflare Pages and Netlify
The examples below assume your build tool writes fingerprinted files to /assets/. Adjust the paths to match your project, then confirm the result in DevTools or with curl -sI https://example.com/assets/app.3f9a1c2b.js.
nginx
# Fingerprinted build output, e.g. /assets/app.3f9a1c2b.js
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Unversioned images and fonts: 30 days
location ~* \.(?:avif|webp|jpe?g|png|gif|svg|ico|woff2)$ {
add_header Cache-Control "public, max-age=2592000";
}
# HTML and everything else: revalidate on every use
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ =404;
}
The ^~ modifier stops nginx from also checking the regex block, so images inside /assets/ keep the immutable rule. Watch one gotcha: an add_header inside a location replaces every add_header inherited from the server block, so repeat your security headers in each location.
Apache (.htaccess)
<IfModule mod_headers.c>
# HTML: revalidate on every use
<FilesMatch "\.html?$">
Header set Cache-Control "no-cache"
</FilesMatch>
# Fingerprinted CSS and JS, e.g. app.3f9a1c2b.js
<FilesMatch "\.[0-9a-f]{8,}\.(css|js|mjs)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# Unversioned images and fonts: 30 days
<FilesMatch "\.(avif|webp|jpe?g|png|gif|svg|ico|woff2)$">
Header set Cache-Control "public, max-age=2592000"
</FilesMatch>
</IfModule>
FilesMatch matches files on disk, so HTML generated by PHP or a framework router needs its header set in the application. If the site also uses mod_expires, check the final header: the two modules can produce conflicting max-age values, so pick one.
Cloudflare Pages and Netlify _headers
Both platforms read a plain-text _headers file from the build output folder. By default they make browsers revalidate every file, which already suits HTML, so you only need to override the fingerprinted folders:
# _headers (Cloudflare Pages and Netlify)
/assets/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
Avoid setting Cache-Control in a catch-all /* rule as well: when several rules match a path, the values can be merged into one contradictory header. See Cloudflare's _headers documentation for matching rules.
Common browser caching mistakes
- no-store on static assets. Security middleware and session handling often add it globally. PHP's default session settings, for example, send
Cache-Control: no-store, no-cache, must-revalidateon every response that starts a session. That is fine for account pages and wasteful for CSS, JavaScript and images. - Short TTLs on files that never change.
max-age=300on a hashed bundle forces a revalidation round trip every five minutes for no benefit. - Set-Cookie on static responses. Most CDNs refuse to cache responses that set cookies, and a misconfigured shared cache could hand one visitor's session cookie to others. Serve assets without cookies.
- Vary misuse.
Vary: Accept-Encodingis normal.Vary: User-AgentorVary: Cookiesplits the cache into thousands of variants, andVary: *makes a response effectively uncacheable. - no-cache without validators. HTML sent with
no-cachebut no ETag or Last-Modified is downloaded in full every time.
Reading repeat-visit results in Global Website Speed Test
When you run a free 8-region speed test, each region loads your page twice in headless Chromium: a first visit with an empty cache, then a repeat visit that reuses whatever the first visit cached. In the regional breakdown, the TTFB, Load time and Transfer columns show the repeat value under each first-visit number, in green when it is lower and red when it is higher.
- Healthy caching: repeat transfer falls to a small fraction of the first visit (mostly the HTML plus a few revalidations), and repeat load time drops most in the far regions, where every avoided round trip saves 150–300 ms.
- Repeat transfer close to the first visit: files are not being cached at all. Look for
no-store, missing headers or URLs that change on every load. - Low repeat transfer but barely faster load: assets are revalidating with
no-cacheor an expired shortmax-age. The 304s save bytes but still cost round trips, so switch them to a long max-age with immutable. - Repeat TTFB: the HTML is normally revalidated, so repeat TTFB stays close to the first visit (reused connections can trim it). If it is high in every region, the server itself is slow.
The Page weight card summarizes this as "repeat faster in X/Y": the number of regions where the repeat visit loaded faster, out of those where both visits completed. That share feeds the grade. A+ needs at least 80% (7 of 8 when all regions succeed), A needs 60%, B 50%, C 37.5% and D 25%, and the report flags "Repeat visits are not faster" below 60%. The methodology page lists every threshold.
max-age=60 passes the test. Real visitors return hours or days later, so check the actual header values too. Once repeat visits are fast, shrink the first visit next; our image optimization guide is the usual place to start.Frequently asked questions
What is the difference between no-cache and no-store?
no-cache lets the browser keep a copy but requires it to check with the server before every reuse, which usually returns a small 304 Not Modified response. no-store forbids keeping a copy at all, so every visit downloads the file again. Use no-store only for sensitive data.
How long should I cache static files?
Files with a content hash in the file name can be cached for one year (max-age=31536000) with immutable, because any change produces a new URL. Files without a hash should get a short max-age, or no-cache with an ETag, so updates are not stuck in caches.
Does browser caching help first-time visitors?
No. The browser cache only speeds up repeat visits and navigation between pages of the same site. First visits depend on server response time, distance and page weight, which is where a CDN and smaller files help.
Why is my repeat visit not faster than the first?
Usually the static files are sent with no-store, a very short max-age or no caching headers at all, or every page load references new file URLs. Check the Cache-Control header of your CSS, JavaScript and image responses in the browser developer tools.
What does stale-while-revalidate do?
It lets a cache serve an expired response immediately while it fetches a fresh copy in the background. With max-age=60, stale-while-revalidate=600, a response is fresh for one minute and can be served stale for ten more minutes without making the visitor wait.


