RE:NODE
Browse hosting

Web hosting11 min read

HTTP caching headers explained: Cache-Control and ETag

What every Cache-Control directive does, how ETags and 304s work, and the two-tier rule that makes a site fast without serving anybody stale HTML.

0 readers

Almost every site needs the same two rules. Files with a content hash in the name - app.9f2c1b.js - get Cache-Control: public, max-age=31536000, immutable, because the name changes when the content does and the browser never needs to ask again. HTML gets Cache-Control: no-cache, which stores the page but revalidates it, so a deploy is visible immediately and a repeat visit still costs one cheap 304 instead of a full download.

That is ninety per cent of web caching. The rest of this post is the other ten per cent: what each directive actually means, why your headers are being ignored, and what a CDN does differently from a browser.

The caches between your server and the visitor#

A response can be stored in more places than most people picture, and each one obeys slightly different rules.

requestonly on a missforwardedCache-Control decidesBrowser cacheone visitor, on diskCDN or shared cachemany visitorsReverse proxyTLS and routingYour serversets the headers
Where a response can be stored on its way back

The distinction that matters is private versus shared. The browser cache belongs to one person, so it may hold their account page. A CDN is shared by everybody, so it must not - and the private directive is how you say so. Getting this backwards is how one customer's order confirmation ends up served to another, which is a bad day that starts with a header.

Your reverse proxy sits in between and normally does not cache at all unless you configure it to. On RE:NODE the proxy slot terminates TLS and forwards to your server; caching decisions stay with your application and with whatever CDN you put in front.

Cache-Control, directive by directive#

Cache-Control is a response header carrying a comma-separated list. These are the directives that earn their place:

DirectiveApplies toWhat it means
max-age=600All cachesFresh for 600 seconds from now
s-maxage=600Shared caches onlyOverrides max-age for CDNs and proxies
publicAll cachesMay be stored even if the request was authenticated
privateBrowser onlyA shared cache must not store it
no-cacheAll cachesStore it, but revalidate before every reuse
no-storeAll cachesDo not write it down at all
must-revalidateAll cachesOnce stale, never serve it without checking
immutableBrowserDo not revalidate, even on a reload
stale-while-revalidate=60All cachesServe stale for 60s while fetching fresh
stale-if-error=86400All cachesServe stale if the origin is failing

Two of those are misread constantly. `no-cache` does not mean do not cache. It means cache it and always ask first, which is exactly what you want for HTML: the round trip is tiny when nothing changed. no-store is the one that means do not keep a copy, and it is for responses that must never touch a disk - a bank statement, a one-time token, a password reset page.

immutable is the directive that makes hashed assets genuinely free on a repeat visit. Without it, a browser reload revalidates every cached file, and you pay a round trip per asset to be told nothing changed. With it, the browser skips the check entirely for the lifetime of the max-age. It is only safe on a URL whose content can never change, which is precisely what a content hash guarantees.

stale-while-revalidate is the cheapest performance win most sites never switch on. max-age=60, stale-while-revalidate=600 means a cached page is served instantly for eleven minutes at worst, with the refresh happening in the background rather than in front of the visitor.

If you send no caching headers at all, caches are allowed to guess. RFC 9111 permits heuristic freshness based on Last-Modified - commonly ten per cent of the time since the document last changed - so a file modified a year ago may be cached for weeks by something in the path. Silence is not the same as "do not cache".

ETag, Last-Modified and the 304#

Freshness says how long a cache may reuse a response without asking. Validation is what happens when it does ask.

  • Last-Modified: Wed, 10 Sep 2026 09:41:12 GMT - a timestamp with one-second resolution.
  • ETag: "6f9a2c-1b4d" - an opaque token that changes when the content does. W/"..." marks a weak tag, meaning semantically equivalent rather than byte-identical.

On the next request the browser sends If-Modified-Since or If-None-Match with the value it holds. If nothing changed, the server answers 304 Not Modified with headers and no body, and the browser uses the copy it already has.

bash
$ curl -sI https://example.com/style.css | grep -i -E "etag|cache-control|last-modified"$ curl -sI -H 'If-None-Match: "6f9a2c-1b4d"' https://example.com/style.css | head -1HTTP/2 304

A 304 saves the bytes but not the round trip. On a connection with 120 ms of latency, forty assets that all return 304 still cost you a slow-feeling page. That is the entire argument for long max-age with immutable on fingerprinted files: the fastest request is the one that never happens.

nginx generates ETag for static files automatically from the modification time and the size, which is stable across servers as long as the files have the same timestamps. A deploy that rewrites every file with a new mtime invalidates every ETag on the site even where the content is identical, so use a copy method that preserves timestamps when it matters. Application frameworks usually hash the response body instead, which is correct but costs CPU on every request.

The two-tier strategy#

Almost every site should end up here:

WhatCache-ControlWhy
HTML pagespublic, no-cacheAlways fresh, cheap 304 on repeat
Hashed CSS, JS, fontspublic, max-age=31536000, immutableThe name changes when the content does
Unhashed CSS and JSpublic, max-age=3600You cannot prove it is unchanged
Uploaded imagespublic, max-age=2592000Rarely replaced in place
Logged-in pagesprivate, no-storeNever in a shared cache
API responsesprivate, max-age=0 or no-storeDecide per endpoint, do not guess

A year - 31536000 seconds - is the conventional maximum for max-age, and there is nothing to gain from a larger number. If your build already writes hashed filenames, as every modern bundler does, the top two rows cover your whole site; static site hosting has the nginx blocks in context.

The tier that causes trouble is the third: a stylesheet at a fixed URL that you edit in place. There is no safe long cache for it, because some visitors will hold the old copy for as long as you told them to. The fix is not a shorter cache, it is a different URL. Every build tool can fingerprint output; if you are hand-writing HTML, append a version query - style.css?v=7 - and change it when you edit. Query strings are part of the cache key for browsers and for most CDNs, though some CDNs can be configured to ignore them, which turns your cache buster into a no-op.

Vary, cookies and the headers that disable caching#

Vary tells caches which request headers change the response. It is necessary and dangerous in equal measure, because every value multiplies the number of copies a cache has to keep.

  • Vary: Accept-Encoding - correct and required when you compress. Without it a cache can hand a gzipped body to a client that did not ask for one.
  • Vary: Cookie - technically correct on a page that differs per user, and in practice it means no shared cache ever gets a hit, because every visitor has a different cookie.
  • Vary: User-Agent - shatters the cache into thousands of variants. Avoid it.

Cookies are the other silent cache killer. A response carrying Set-Cookie cannot be stored by a shared cache for reuse by other people, and most CDNs simply bypass the cache when they see one. That is the right default, and it is why an analytics or consent script that sets a cookie server-side on every page can halve your cache hit rate without anybody noticing.

PHP does this to you by default. The first call to session_start() sends, for the whole response:

code
Expires: Thu, 19 Nov 1981 08:52:00 GMTCache-Control: no-store, no-cache, must-revalidatePragma: no-cache

That is session.cache_limiter doing its job, and it is correct for a page showing somebody's account. It is wrong for a public page that happens to start a session for no reason. Either do not start the session on pages that do not need one, or set session_cache_limiter('public') before session_start() and take responsibility for what you are doing.

WordPress and most CMS page caches follow the same logic: anything with a login cookie bypasses the cache entirely, which is why an admin testing the site sees the slow path and reports that caching is not working. Test in a private window. WordPress speed and caching goes through the layers in that specific case.

CDNs and shared caches#

A CDN is a shared cache in many locations. It reads the same headers, with two additions worth knowing.

s-maxage applies only to shared caches, which lets you keep HTML fresh in browsers and cached at the edge: Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=3600. Browsers revalidate every time, the CDN serves from its own copy for ten minutes and refreshes in the background, and your origin sees a fraction of the traffic. For a site whose pages are the same for everyone, this is the single biggest change you can make.

CDN-Cache-Control is a targeted header that only CDNs read, so you can give them different instructions without touching what browsers see. Older infrastructure uses Surrogate-Control for the same purpose. Support varies, so check your provider's documentation before relying on it - Cloudflare for websites and game servers covers what the common one does with these.

Two operational habits go with a CDN. First, know how to purge, by URL and by tag, and put the purge in your deploy script rather than in a runbook nobody opens. Second, read the cache status header your provider sends - HIT, MISS, EXPIRED, BYPASS - because it turns "is caching working" from an argument into a fact. If you are seeing BYPASS, look for a cookie.

A CDN does not fix a slow origin for uncached requests. The first visitor to each page, in each location, still waits for your server, and so does every request a cookie makes uncacheable. TTFB, Core Web Vitals and hosting covers which part of the wait is yours to fix.

Setting the headers#

On nginx, static assets by extension and HTML separately:

nginx
location ~* \.(?:css|js|woff2|avif|webp|png|jpg|svg)$ {    expires 1y;    add_header Cache-Control "public, max-age=31536000, immutable" always;    access_log off;}location ~* \.html$ {    add_header Cache-Control "public, no-cache" always;}

expires 1y; sets both the legacy Expires header and a matching max-age, which is why the explicit add_header follows it: you want the extra directives.

The nginx trap that catches everyone: add_header directives are inherited from the enclosing block only if the current block defines none of its own. Add a single add_header inside a location and every header set in the parent server block disappears from those responses - including your security headers. If you use add_header in a location, repeat everything the parent set. The always flag makes the header apply to error responses too, not only 2xx and 3xx.

From PHP, set it before any output, and remember that output buffering settings decide how early "before any output" really is - php.ini settings that matter covers the ones that bite:

code
header('Cache-Control: public, max-age=300, stale-while-revalidate=600');

From an application framework, use its response helpers rather than raw headers, because the framework may add its own later and the last one written wins.

Checking what you actually send#

Never trust the configuration; read the wire. One command tells you most of it:

bash
$ curl -sI https://example.com/ | grep -i -E "cache-control|etag|age|vary|set-cookie"

What to look for, in order:

  1. `Set-Cookie` on a public page. Almost always an accident, and it disables shared caching.
  2. `Age`. Present means something in front of you served a stored copy, and its value is how old that copy is.
  3. `Vary`. Anything beyond Accept-Encoding needs a reason.
  4. Conflicting directives. no-cache together with max-age=3600 is legal and confusing; no-store beats everything.
  5. The browser devtools network panel. The size column showing "disk cache" or "memory cache" with no network time is what a working cache looks like. Remember that a hard reload bypasses the cache and tells you nothing about the real experience.

When a change to headers appears to do nothing, check in this order: is a CDN in front serving an old copy, is your browser holding a copy from before the change, and is a location block further down the configuration overriding you. It is one of those three about eighty per cent of the time.

FAQ#

What is the difference between no-cache and no-store?

no-cache stores the response and revalidates it before every reuse, so you still get a 304 and a fast page. no-store forbids writing it down anywhere. Use no-cache for HTML that changes, and no-store only for genuinely sensitive responses - it also disables the browser's back/forward cache, which makes navigation feel slower.

Should I use ETag or Last-Modified?

Both, if they are free. Last-Modified has one-second resolution and is fine for files; ETag is exact and works for generated responses. Serving both lets the client use whichever it prefers. Do not disable ETags unless you have a specific multi-server problem that you have measured.

Why is my CSS still the old version after a deploy?

Because you told browsers to keep it. If the URL did not change and you sent a long max-age, the only cure is time or a new URL. Fingerprint your assets so this cannot happen again, and keep the long cache for files whose names encode their content.

Does caching help a site with very little traffic?

It helps repeat visitors and it helps every page load after the first, which is most of what a visitor experiences. It does not help the first request on a cold cache. For a small site the bigger win is usually the origin itself being quick, plus compression.

How long should images be cached?

Thirty days is a reasonable default for uploaded media, a year if the filename includes a hash. The failure mode is replacing an image in place and having the old one persist, so prefer uploading a new file with a new name to overwriting one.

Do these headers affect search rankings?

Not directly. They affect how fast pages load for real visitors, which is measured in the field and does feed into ranking signals through Core Web Vitals. Caching is worth doing for the users; the ranking is a side effect.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000