The team has optimised the bundle. Images are compressed. The LCP element loads in under 2.5 seconds on a fast connection. But on repeat visits — or for returning users landing on a different page of the same site — the browser still fires requests for assets it already has. Every request that could have been answered from a local or shared cache adds latency, consumes bandwidth, and puts load on the origin.
HTTP caching headers are the mechanism that controls whether a browser or CDN can reuse a previously fetched response, for how long, and under what conditions. Get them right and the entire repeat-visit experience speeds up without any code changes. Get them wrong — or leave them at defaults — and every asset is either re-requested unnecessarily or, worse, stale content is served long after it should have been replaced.
This guide covers the caching headers that actually matter in production, what each directive controls, how browser caches and shared caches differ, and how to build a targeted caching strategy for static assets, dynamic HTML, and API responses.
TL;DR
- Caching headers tell browsers and shared caches how long a response can be reused and when it must be revalidated with the origin.
- Static assets and HTML should almost never share the same caching policy.
immutableworks best on fingerprinted, content-hashed assets whose URL changes whenever the content changes.stale-while-revalidateimproves repeat-visit perceived speed by serving a cached copy while revalidating in the background.- Validators (
ETag,Last-Modified) make revalidation cheaper when content may have changed — but they are not freshness policies by themselves. - The safest cache strategy depends on whether the response is versioned, dynamic, personalised, or shared.
Before tuning cache policies, confirm what headers your server is actually sending. The CodeAva HTTP Headers Checker fetches the live response headers for any public URL and categorises them into security, caching, and general signals — a quick way to see what browsers and intermediaries are receiving before you start changing configuration.
Browser cache vs CDN / shared cache: not the same layer
HTTP caching operates at multiple layers, and a single response can be cached differently at each one. Treating them interchangeably is one of the most common sources of confusion.
- Browser (private) cache.Stored locally on the user’s device. Only that user benefits. Controlled by
max-age,private,no-cache, andno-store. A private cache may store personalised responses. - Shared cache (CDN, reverse proxy). Sits between users and the origin. Multiple users share the same cached copy. Controlled by
s-maxage,public, and the absence ofprivate. A shared cache must not store responses markedprivate.
A response with Cache-Control: max-age=60, s-maxage=3600 tells the browser to revalidate after 60 seconds but allows a CDN to serve its cached copy for up to an hour. This is a common and intentional split: users get fresh content reasonably quickly, and the CDN absorbs the majority of origin traffic.
Not every site has a CDN
s-maxage has no effect if no shared cache is present. Before setting shared-cache directives, confirm that a CDN or reverse proxy actually sits in front of your origin. If you are unsure, inspect the Via, X-Cache, or CF-Cache-Status response headers.| Property | Browser (private) cache | CDN / shared cache |
|---|---|---|
| Scope | Single user, single device | All users behind the cache |
| Freshness control | max-age | s-maxage (overrides max-age) |
| Personalised content | Safe to cache with private | Must not cache — use private to prevent it |
| Stale serving | stale-while-revalidate | stale-while-revalidate (if CDN supports it) |
| Typical latency benefit | Eliminates network round trip entirely | Eliminates origin round trip; edge latency remains |
| Invalidation | Hard-refresh or new URL (cache bust) | Purge API, surrogate keys, or TTL expiry |
The Cache-Control directives that actually matter
Cache-Control is the primary header for caching policy. It replaced the older Expires / Pragma model and is supported by every modern browser and CDN. The directives below cover the vast majority of real-world use cases.
max-age
Sets the freshness lifetime in seconds for all caches (browser and shared). While the response is fresh, the cache may serve it without contacting the origin.
Cache-Control: max-age=3600The browser will reuse the cached response for up to one hour. After that, it revalidates with the origin (or fetches a fresh copy if no validator headers are present).
s-maxage
Overrides max-age specifically for shared caches (CDNs, reverse proxies). The browser ignores it. This lets you set a short browser TTL and a longer CDN TTL in a single header:
Cache-Control: max-age=60, s-maxage=3600Browsers revalidate after 60 seconds; the CDN serves its copy for up to an hour.
no-cache
no-cachedoes not mean “do not cache.” It means: you may store a copy, but you must revalidate with the origin before serving it. The cache sends a conditional request (using If-None-Match or If-Modified-Since), and the origin either responds with 304 Not Modified (reuse the stored copy) or sends the full fresh response.
Cache-Control: no-cacheThis is appropriate for HTML pages, API responses that change unpredictably, and any content where serving a stale copy is worse than the cost of a revalidation round trip.
no-store
no-storeis the “do not cache” directive. No cache — browser or shared — may retain any copy of the response. Every request goes to the origin.
Cache-Control: no-storeUse no-store for sensitive data: banking pages, authenticated API responses containing tokens, and any content where retaining a copy poses a security or privacy risk.
public
Explicitly marks a response as cacheable by shared caches, even if the request included an Authorization header (which normally makes the response private). Use public only on responses that are genuinely safe for all users to share.
Do not use public on personalised or sensitive responses
public may be stored and served by a CDN to any user. If the response contains personalised content, session data, or anything user-specific, use privateinstead — or no-store if no caching is acceptable.private
Restricts caching to the browser only. Shared caches (CDNs, reverse proxies) must not store the response. Use this for responses that are safe to cache on the user’s device but must not be shared across users.
Cache-Control: private, max-age=300The browser may reuse the response for five minutes. The CDN will not cache it.
immutable
Tells the cache that the response body will never change at this URL. The cache should not attempt to revalidate even if the user navigates or refreshes. This is powerful for fingerprinted static assets whose URL changes whenever the content changes:
Cache-Control: public, max-age=31536000, immutableWith this header, the browser will not revalidate the asset for one year, and immutable prevents revalidation even on manual refresh in supporting browsers. But if the URL does not include a content hash, immutableis dangerous — the only way to update the content is to change the URL.
stale-while-revalidate
Specifies a window (in seconds) after the response becomes stale during which the cache may serve the stale copy while fetching a fresh one in the background:
Cache-Control: max-age=60, stale-while-revalidate=300For the first 60 seconds, the response is fresh and served directly. For the next 300 seconds, the cache serves the stale copy instantly and revalidates in the background. After 360 seconds total, the cache must revalidate before serving.
This is particularly effective for content that changes periodically but where a brief staleness window is acceptable — blog listings, product pages, non-personalised API responses. The user never waits for the revalidation round trip.
Static assets: the safest long-cache pattern
Fingerprinted JavaScript, CSS, fonts, and images are the ideal candidates for aggressive caching. When the build system includes a content hash in the filename — app.3f8a2c.js, styles.d9e2f1.css — the URL itself is the version. If the content changes, the filename changes, and the old URL is never requested again.
The recommended header for fingerprinted assets:
Cache-Control: public, max-age=31536000, immutablepublic— both the browser and CDN may cache the asset.max-age=31536000— one year. The asset is fresh for the maximum practical duration.immutable— do not attempt to revalidate, even on manual refresh.
Aggressive caching without versioning is dangerous
max-age means users will be stuck with the old version until the TTL expires or they hard-refresh. This is one of the most common caching mistakes in production. Only use long TTLs on URLs whose content will never change.Common patterns by asset type:
| Asset type | Versioned (hash in filename) | Unversioned |
|---|---|---|
| JS / CSS bundles | public, max-age=31536000, immutable | no-cache or short max-age with ETag |
| Fonts | public, max-age=31536000, immutable | public, max-age=86400 with ETag |
| Images | public, max-age=31536000, immutable | public, max-age=86400 with ETag |
| Favicons | Rarely versioned | public, max-age=86400 |
Dynamic HTML and API responses: a different strategy
HTML pages and API responses are fundamentally different from versioned static assets. They change at unpredictable intervals, may contain personalised content, and referencing stale asset URLs can break the entire page. The caching strategy must reflect this.
HTML pages
Most HTML pages should use no-cache (or the equivalent max-age=0, must-revalidate) with a strong validator:
Cache-Control: no-cache
ETag: "a1b2c3d4e5"The browser stores the response but revalidates on every navigation. If the content has not changed, the origin responds with 304 Not Modified— a tiny response that avoids re-transferring the full HTML body. This gives freshness without the full cost of an uncached fetch.
For content that changes periodically and where brief staleness is acceptable (blog listings, product category pages), stale-while-revalidate improves repeat-visit performance:
Cache-Control: max-age=60, stale-while-revalidate=300API responses
APIs need more nuance. The right policy depends on who consumes the response:
- Public, non-personalised data (product catalogue, pricing, feature flags): short
max-agewiths-maxagefor CDN offloading. - User-specific data (profile, cart, settings):
private, no-cacheorprivate, max-age=60— neverpublic. - Sensitive data (tokens, session state, financial data):
no-store.
Validators: ETag and Last-Modified
ETag and Last-Modified are not freshness policies. They are validators — they make revalidation cheaper by enabling conditional requests. When a cached response becomes stale, the cache sends a conditional request to the origin:
If-None-Match: "etag-value"— the origin compares the ETag. If it matches, the content has not changed, and the origin responds with304 Not Modified(no body).If-Modified-Since: Tue, 15 Apr 2026 10:00:00 GMT— the origin checks whether the resource has been modified since that timestamp. If not, it responds with304.
A 304response is tiny — just headers, no body. For large HTML pages or JSON payloads, this can save significant transfer size and parsing time on repeat visits.
ETag vs Last-Modified
| Property | ETag | Last-Modified |
|---|---|---|
| Precision | Byte-level (hash of content) | Second-level (timestamp) |
| Conditional request header | If-None-Match | If-Modified-Since |
| Dynamic content | Works well — hash the response body | Unreliable if content changes within the same second |
| Static files | Reliable; most servers auto-generate | Reliable if filesystem timestamps are accurate |
| Multi-server consistency | Strong ETags must match across all servers (use weak ETags or consistent hashing) | Timestamps must be synchronised (NTP) |
Many servers send both. If you must choose one, ETag is generally more reliable because it is content-derived rather than time-derived. But Last-Modified is simpler to generate and sufficient for static files served from a single server.
Caching strategy by response type
The following table summarises the recommendedCache-Controlpolicy by response type. These are starting points — adjust based on your architecture, CDN capabilities, and tolerance for staleness.
| Response type | Recommended Cache-Control | Validator |
|---|---|---|
| Versioned static assets | public, max-age=31536000, immutable | Optional (URL is the version) |
| Unversioned static assets | public, max-age=86400 | ETag or Last-Modified |
| HTML pages | no-cache | ETag |
| Public API (non-personalised) | max-age=60, s-maxage=300, stale-while-revalidate=60 | ETag |
| User-specific API | private, no-cache | ETag |
| Sensitive / authenticated | no-store | None |
Common caching mistakes in production
- Caching HTML with a long
max-age. Users see outdated content and stale asset references. HTML changes unpredictably; useno-cachewith a validator. - Using
no-cachewhenno-storeis needed.no-cachestill stores the response. If the content is sensitive and must never be retained, useno-store. - Setting
immutableon unversioned URLs. Without a content hash in the URL, there is no way to bust the cache short of the TTL expiring. Users are stuck with the old version. - Mixing
publicwith personalised responses. A CDN may serve one user’s personalised page to another. Useprivatefor any user-specific content. - Relying on
s-maxagewithout a CDN. If no shared cache sits between the browser and origin,s-maxagehas no effect. - Omitting validators on
no-cacheresponses. Without anETagorLast-Modified, the cache cannot send a conditional request and must download the full response on every revalidation. - Ignoring
Vary. If the server returns different content based onAccept-Encoding,Accept-Language, or a cookie, theVaryheader must list those request headers. Without it, caches may serve the wrong variant.
The caching audit checklist
Work through these checks for representative pages from each response category (HTML, versioned assets, unversioned assets, public API, authenticated API):
- Inspect the live
Cache-Controlheader. Usecurl -I, browser DevTools, or the HTTP Headers Checker to confirm what the server is actually sending. - Confirm versioned assets use
public, max-age=31536000, immutable. Check a JS bundle and a CSS file from the build output. - Confirm HTML pages use
no-cache(or a shortmax-agewithmust-revalidate). Verify the HTML is not accidentally inheriting a long TTL from a CDN default. - Confirm an
ETagorLast-Modifiedheader is present on cacheable responses. Without a validator, revalidation costs the same as a full fetch. - Confirm personalised responses are marked
privateorno-store. Authenticated pages, user-specific API responses, and anything with session data must never bepublic. - Confirm
s-maxageis only used when a shared cache is present. If the site has no CDN,s-maxageis harmless but misleading. - Confirm
Varyis set correctly for content-negotiated responses. At minimum,Vary: Accept-Encodingfor compressed resources. - Test cache behaviour on repeat visits. Open DevTools, navigate to a page, navigate away, and navigate back. Check the Size column for
(disk cache)or(memory cache)entries. If assets are re-fetched, the caching policy is not working as intended. - Compare origin headers with edge headers. If a CDN is present, fetch the same URL from both the origin and the edge. Confirm the CDN is not stripping or overriding your
Cache-Controlheader. - Check for
Set-Cookieon cacheable responses. A response withSet-Cookieandpublicis a cache poisoning risk. Either remove the cookie or mark the responseprivate.
Caching and LCP: the connection
Largest Contentful Paint measures how quickly the largest visible element renders. For repeat visitors, caching is the single most effective lever: a cache-served hero image, font file, or CSS bundle eliminates the network wait entirely and can shave hundreds of milliseconds off LCP.
If repeat-visit LCP is slower than expected, the first diagnostic step is to confirm the LCP resource is being served from cache. If it is not, the caching headers are the likely bottleneck. For the full LCP debugging workflow — including preloading, render-blocking resources, and server timing — see LCP Debugging Checklist for Modern Frontends.
Caching headers and security headers: overlap and tension
Caching and security headers operate on the same responses and sometimes conflict. A few interactions to watch for:
- HSTS (
Strict-Transport-Security) only works over HTTPS. If caching serves a stale HTTP response, HSTS may not be applied. Ensure all responses are served over HTTPS before adding longmax-agevalues to HSTS. - CSP nonces change per request. If a CDN caches HTML with a CSP nonce, the cached nonce will not match inline scripts on the next request, breaking the page. HTML with CSP nonces should not be cached in shared caches, or nonces should be replaced at the edge.
Set-Cookieon shared-cached responses is a cache poisoning risk. Never combinepublicwithSet-Cookie.
For the full production security-headers checklist — including HSTS, CSP, X-Content-Type-Options, and permissions policy — see Security Headers Every Production Website Should Send.
Caching headers are performance infrastructure
HTTP caching headers are not an optimisation you bolt on after launch. They are performance infrastructure: they control how many round trips happen, how much bandwidth is consumed, how much load hits the origin, and how fast the experience feels on repeat visits. The difference between a well-cached site and a poorly-cached one is often larger than any bundle-size optimisation.
The strategy is not complicated, but it is specific: version your static assets and cache them aggressively, revalidate your HTML, protect personalised responses with private or no-store, and confirm that validators are present so revalidation is cheap. Then verify the live headers, not just the configuration.
To check what headers your server is actually sending, the CodeAva HTTP Headers Checker inspects response headers for any public URL and categorises them into security, caching, and general signals. For LCP debugging, see LCP Debugging Checklist for Modern Frontends. For the production security-headers baseline, see Security Headers Every Production Website Should Send.






