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

A browser window pulling CSS, JavaScript, font and image files from a local cache drawer instead of a distant server, with a stopwatch showing a faster second visit

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 Modified with 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-Modified date. 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. 31536000 is 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 Authorization header. 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-cache does not mean "don't cache". It means "check before reuse" and works well for HTML. 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.

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 typeExampleCache-ControlWhy
HTML pages/pricingno-cache plus ETagDeploys appear immediately; unchanged pages cost a 304
Hashed CSS and JSapp.3f9a1c2b.jspublic, max-age=31536000, immutableAny change produces a new file name
Unhashed CSS and JS/js/main.jspublic, max-age=3600Limits staleness to an hour; add hashes when you can
Hashed imageshero.8d2e41.avifpublic, max-age=31536000, immutableSame logic as hashed CSS and JS
Unversioned images/uploads/team.jpgpublic, max-age=259200030 days; upload replacements under a new name
Fontsinter-latin.woff2public, max-age=31536000, immutableFonts rarely change; version the file name if they do
Public API JSON/api/statspublic, max-age=60, s-maxage=300, stale-while-revalidate=600Short browser freshness; the CDN absorbs traffic
Personalized API JSON/api/meprivate, no-cacheNever stored in shared caches; checked on each use
Sensitive dataAccount pages, tokensno-storeMust 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=42 works 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-revalidate on 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=300 on 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-Encoding is normal. Vary: User-Agent or Vary: Cookie splits the cache into thousands of variants, and Vary: * makes a response effectively uncacheable.
  • no-cache without validators. HTML sent with no-cache but 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-cache or an expired short max-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.

The repeat visit runs moments after the first, so even 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.

DevTeam Engineering builds and runs Global Website Speed Test, a free tool that measures page speed from 8 AWS regions at once. We publish guides based on what we see in thousands of multi-region tests. How we test.

Related guides

World map with one origin server in Virginia and CDN edge servers in London, Frankfurt, Singapore, Tokyo and Sydney answering nearby visitors from cache
CDN & Edge

What a CDN is and how it makes your website fast worldwide

A CDN serves files and cached HTML from edge servers near each visitor. Learn cache hits, keys, TTLs, purging and headers, and how a CDN cuts far-region TTFB.

9 min read · Updated Oct 4, 2026
Request timeline with DNS, TCP and TLS segments followed by a long server-wait bar that shrinks to a short one after HTML is cached at a CDN edge
Server & TTFB

How to reduce TTFB (time to first byte): causes, targets and fixes that work

Reduce TTFB by caching HTML at the edge, fixing slow database queries, removing redirects and moving content closer to users. Targets, causes and configs.

9 min read · Updated Oct 4, 2026
A large camera photo being compressed into smaller AVIF and WebP files at several widths, with arrows pointing to a phone, a laptop and a desktop screen
Images & Media

Image optimization for web performance: AVIF, WebP, responsive images and lazy loading

Image optimization guide: pick AVIF or WebP, set quality, serve responsive images with srcset, prevent CLS, lazy-load correctly and prioritize the LCP image.

9 min read · Updated Oct 4, 2026