Mastodon
All articles
Web Performance

How to Use HTTP Caching Headers to Improve Real-World Performance

Learn how to use Cache-Control, ETag, Last-Modified, stale-while-revalidate, and shared-cache directives to improve real-world performance without breaking freshness.

Share:

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.
  • immutable works best on fingerprinted, content-hashed assets whose URL changes whenever the content changes.
  • stale-while-revalidate improves 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, and no-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 of private. A shared cache must not store responses marked private.

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

Many applications serve directly from the origin or from a simple hosting provider with no shared cache layer. 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.
PropertyBrowser (private) cacheCDN / shared cache
ScopeSingle user, single deviceAll users behind the cache
Freshness controlmax-ages-maxage (overrides max-age)
Personalised contentSafe to cache with privateMust not cache — use private to prevent it
Stale servingstale-while-revalidatestale-while-revalidate (if CDN supports it)
Typical latency benefitEliminates network round trip entirelyEliminates origin round trip; edge latency remains
InvalidationHard-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=3600

The 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=3600

Browsers 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-cache

This 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-store

Use 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

A response marked 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=300

The 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, immutable

With 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=300

For 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, immutable
  • public— 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

If asset filenames do not include a content hash, a long 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 typeVersioned (hash in filename)Unversioned
JS / CSS bundlespublic, max-age=31536000, immutableno-cache or short max-age with ETag
Fontspublic, max-age=31536000, immutablepublic, max-age=86400 with ETag
Imagespublic, max-age=31536000, immutablepublic, max-age=86400 with ETag
FaviconsRarely versionedpublic, 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=300

API 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-age with s-maxage for CDN offloading.
  • User-specific data (profile, cart, settings): private, no-cache or private, max-age=60 — never public.
  • 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 with 304 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 with 304.

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

PropertyETagLast-Modified
PrecisionByte-level (hash of content)Second-level (timestamp)
Conditional request headerIf-None-MatchIf-Modified-Since
Dynamic contentWorks well — hash the response bodyUnreliable if content changes within the same second
Static filesReliable; most servers auto-generateReliable if filesystem timestamps are accurate
Multi-server consistencyStrong 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 typeRecommended Cache-ControlValidator
Versioned static assetspublic, max-age=31536000, immutableOptional (URL is the version)
Unversioned static assetspublic, max-age=86400ETag or Last-Modified
HTML pagesno-cacheETag
Public API (non-personalised)max-age=60, s-maxage=300, stale-while-revalidate=60ETag
User-specific APIprivate, no-cacheETag
Sensitive / authenticatedno-storeNone

Common caching mistakes in production

  • Caching HTML with a long max-age. Users see outdated content and stale asset references. HTML changes unpredictably; use no-cache with a validator.
  • Using no-cache when no-store is needed. no-cache still stores the response. If the content is sensitive and must never be retained, use no-store.
  • Setting immutable on 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 public with personalised responses. A CDN may serve one user’s personalised page to another. Use private for any user-specific content.
  • Relying on s-maxage without a CDN. If no shared cache sits between the browser and origin, s-maxage has no effect.
  • Omitting validators on no-cache responses. Without an ETag or Last-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 on Accept-Encoding, Accept-Language, or a cookie, the Vary header 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):

  1. Inspect the live Cache-Control header. Use curl -I, browser DevTools, or the HTTP Headers Checker to confirm what the server is actually sending.
  2. Confirm versioned assets use public, max-age=31536000, immutable. Check a JS bundle and a CSS file from the build output.
  3. Confirm HTML pages use no-cache (or a short max-age with must-revalidate). Verify the HTML is not accidentally inheriting a long TTL from a CDN default.
  4. Confirm an ETag or Last-Modified header is present on cacheable responses. Without a validator, revalidation costs the same as a full fetch.
  5. Confirm personalised responses are marked private or no-store. Authenticated pages, user-specific API responses, and anything with session data must never be public.
  6. Confirm s-maxage is only used when a shared cache is present. If the site has no CDN, s-maxage is harmless but misleading.
  7. Confirm Vary is set correctly for content-negotiated responses. At minimum, Vary: Accept-Encoding for compressed resources.
  8. 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.
  9. 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-Control header.
  10. Check for Set-Cookie on cacheable responses. A response with Set-Cookie and public is a cache poisoning risk. Either remove the cookie or mark the response private.

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 long max-age values 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-Cookie on shared-cached responses is a cache poisoning risk. Never combine public with Set-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.

#http-caching#cache-control#performance#cdn#browser-cache#etag#last-modified#stale-while-revalidate#immutable#s-maxage#static-assets#web-performance

Frequently asked questions

More from Sophia DuToit

Found this useful?

Share:

Want to audit your own project?

These articles are written by the same engineers who built CodeAva\u2019s audit engine.