alion tech studio
  • services
  • faq
  • insights
  • about
  • contact
← back to insights
  1. home
  2. insights
  3. http caching headers
jun 26, 2026 • 9 min read • web performance

http caching headers: Cache-Control, ETags, and browser caching done right

By Alex I

HTTP caching is one of those topics where most engineers know the names — Cache-Control, ETag, Last-Modified — but lack a clear mental model of how they fit together. The result is caches left at zero by default, pages that re-download identical assets on every visit, and CDN configurations that accidentally cache the wrong thing. This article draws a clear map of how browser and CDN caching work, which directives matter, and what the right strategy looks like by resource type.

two places caching happens

Before diving into headers, it helps to be precise about where caching happens, because the same header can have different effects in different contexts.

Browser cache: every browser keeps a local store of responses. When a page requests a resource it has seen before, the browser checks its cache first. If the cached response is still valid, the network is not touched at all — the file is read from disk.

CDN / shared cache: a CDN sits between your origin server and many users. It caches responses and serves them to multiple visitors, so a request that hits the CDN edge never reaches your origin at all. CDN cache is shared — many different people receive the same cached response.

These two layers have different concerns. A browser cache is private to the person who made the original request. A CDN cache is shared across all visitors. Some Cache-Control directives explicitly target one layer or the other, and conflating them is the source of many caching bugs.

Cache-Control: the directives that matter

Cache-Control can appear in both requests and responses, but it is the response header that controls caching behaviour. A response can carry multiple directives as a comma-separated list: Cache-Control: public, max-age=3600, stale-while-revalidate=60.

max-age=N tells caches — both browser and CDN — to treat the response as fresh for N seconds from the time of the original request. Within that window, the cached copy is served without contacting the server at all. After it expires, the cache revalidates.

s-maxage=N is the CDN-specific equivalent of max-age. When both are present, CDNs use s-maxage and browsers use max-age. This lets you set a long CDN cache lifetime independently of what the browser caches. Useful for pages that benefit from CDN caching but should not be stored too long in browser caches where personalization matters.

no-cache is widely misunderstood. It does not mean "do not cache." It means "cache this response, but always revalidate it before using the cached copy." Every request still involves a network round-trip, but if the resource has not changed, the server returns 304 Not Modified with no body, and the browser uses its stored copy. The round-trip happens, but the download does not — which is much cheaper than a full re-download.

no-store actually means "do not cache this at all, anywhere." Appropriate for responses containing data that must never sit in any cache: bank statements, session tokens, health records, anything that should not persist beyond the current request.

must-revalidate means that once a cached response is stale, the cache must not serve it without checking with the origin first. Without this directive, some caches are permitted to serve stale content when the origin is unreachable. Adding must-revalidate closes that door — a stale response is not served if the origin can be reached to verify freshness.

private tells shared caches (CDNs, proxies) not to cache the response, while still allowing the browser to cache it privately. Use this for any response that is personalised to the current user: pages showing account data, API responses containing user-specific content, anything that should not be shared across visitors via a CDN.

public explicitly marks a response as cacheable by shared caches, even when it is served over HTTPS or carries an Authorization header (where shared caching is otherwise prohibited by default). Non-authenticated resources that you want CDN-cached should carry this explicitly.

stale-while-revalidate=N is one of the more useful modern directives. It tells the cache: if the response is stale but within N seconds past expiry, serve the stale copy immediately while fetching a fresh one in the background. The user gets an instant response; the cache gets updated asynchronously. This is a controlled trade of absolute freshness for lower perceived latency — particularly useful for API responses or pages where a slightly stale version is acceptable.

ETags and conditional requests

An ETag is a server-assigned identifier for a specific version of a resource — typically a hash of the content, a database revision ID, or a timestamp. When a cached response expires and the cache revalidates, it sends the stored ETag back to the server in an If-None-Match request header. If the resource has not changed, the server returns 304 Not Modified with no body and negligible bandwidth cost. If the content has changed, the server returns 200 OK with the updated content and a new ETag.

Last-Modified and the corresponding If-Modified-Since conditional header serve the same purpose using timestamps. ETags are generally preferred: they survive regenerations that update timestamps without changing content, they work correctly when content changes more than once per second, and they are more precise in edge cases involving byte-range requests and compression.

Strong ETags ("abc123") require byte-for-byte identical responses. Weak ETags (W/"abc123") indicate that two responses are semantically equivalent even if not bit-for-bit identical — for example, the same HTML served with different gzip compression levels. Most web servers and frameworks generate strong ETags by default. Most applications do not need to reason about weak vs strong unless they are doing very fine-grained caching of compressed resources.

the immutable asset strategy

For versioned static assets — JavaScript bundles, CSS files, compiled fonts, processed images — the right strategy is to include a content hash in the filename and serve those files with an aggressive, never-expiring cache header:

Cache-Control: public, max-age=31536000, immutable

The immutable directive tells browsers they should never revalidate this URL, even when it technically expires, because the URL itself guarantees the content has not changed. The content hash in the filename is the version signal: when the content changes, the build tool generates a new hash, producing a new URL. The old URL and its cached response are simply abandoned — the HTML document starts referencing the new URL, and a fresh download happens for that new resource. Old cached responses are never stale because their URLs are never reused.

This eliminates the main danger of aggressive caching (serving outdated JavaScript or CSS after a deploy) while giving every returning visitor instant asset loads for the full lifetime of their browser cache.

The HTML document itself is the coordination layer. It must be served with either Cache-Control: no-cache or a very short max-age, so that every navigation always fetches the current HTML with the current asset references. If the HTML is stale, no amount of clever asset hashing helps — the browser will load the old page with the old script URLs.

the Vary header

Vary tells a cache which request headers were used to produce this response, and therefore which requests can be served from this cached entry. It is required whenever the same URL can return different content depending on a request header.

The most common use is Vary: Accept-Encoding, which tells the cache to store separate copies for gzip, Brotli, and uncompressed responses. Without this, a CDN that caches a gzip-compressed response might serve it to a client that does not support gzip.

Less obviously: if your server serves different HTML for mobile and desktop at the same URL, you need Vary: User-Agent — or preferably, use responsive design so you do not need to vary at all. Vary: Cookie or Vary: Authorization are legitimate for personalized responses but essentially disable CDN caching, since every credential combination produces a different cache key. In practice, for authenticated content it is usually better to use Cache-Control: private and forgo CDN caching entirely.

common caching mistakes

Setting max-age=0 on everything as a "safe" default. Teams that are nervous about caching often do this. The result is every visitor re-downloads identical CSS, JavaScript, and images on every page load — all the work of building a fast site, undermined by zero-second caches. Zero is not safe; it is just slow. The actually safe default for versioned assets is maximum-age-plus-immutable. The actually safe default for HTML is no-cache with an ETag.

Caching authenticated API responses at the CDN level without Vary. If your CDN caches a JSON response for user A and then serves it to user B, that is a data leak. Always apply Cache-Control: private to personalised responses, and only CDN-cache responses that are identical for every visitor.

Confusing CDN cache invalidation with browser cache invalidation. Purging your CDN after a deploy updates what the CDN serves. It does nothing for users who already have the old asset in their browser cache. If you serve files without content-hashed URLs and with long max-age headers, those users will see the old version until their cache expires. Content-hashed URLs solve this — the new asset is at a new URL, so the browser cache for the old URL is irrelevant.

Missing Vary: Accept-Encoding. If your server compresses responses and your CDN caches them, you must include this header. Without it, a CDN may serve a Brotli-compressed response to a client that sent Accept-Encoding: gzip, producing an undecodable response. Most CDNs and reverse proxies add this automatically when they compress, but verify it in the response headers of your CDN-served assets.

a strategy by resource type

Bringing it all together, here is a practical starting point for the main categories of resources:

  • HTML documents: Cache-Control: no-cache plus an ETag. Users always get the latest HTML; unchanged documents cost only a 304 round-trip, not a full re-download.
  • Versioned static assets (JS, CSS, compiled fonts): Cache-Control: public, max-age=31536000, immutable. Never expires in practice — a URL change handles versioning.
  • Unversioned images and media: Cache-Control: public, max-age=86400. One day is a reasonable default; adjust based on how often images change. Add an ETag so unchanged images cost only a conditional request on revalidation.
  • Public API responses (non-personalised): Cache-Control: public, s-maxage=60, stale-while-revalidate=30. Fresh from CDN for a minute, served stale while revalidating for an additional 30 seconds.
  • Authenticated or personalised API responses: Cache-Control: private, no-store. Nothing cached anywhere.

These are starting points, not dogma. The right values depend on how frequently your content changes, your tolerance for serving briefly stale data, and whether you have a CDN and how you manage cache invalidation. What matters is making each decision deliberately, knowing which layer it targets, and verifying the headers in your browser's developer tools before you ship.

AI

Alex I

Software engineer and founder of alion tech studio. Writes and consults on web performance, security, mobile apps, backend systems, cloud infrastructure, and fullstack architecture.

related reading

may 28, 2026 • 9 min read • web performance

a practical field guide to core web vitals in 2026

read article →
may 2, 2026 • 8 min read • cloud engineering

the power of serverless edge rendering on static platforms

read article →
alion tech studio

an independent software engineering studio. we write about performance, security, and building for the web, and consult on the same.

navigation

  • services
  • faq
  • insights
  • about
  • contact

legal

  • privacy policy
  • terms of service

© 2026 alion tech studio. all rights reserved.