HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test
The fastest request is the one that never reaches your server. HTTP caching lets browsers and CDNs reuse responses safely. We ran a tiny server to watch freshness, revalidation and 304 responses happen, then turned it into rules you can apply today.
- Cache-Control says how long a response stays fresh and who may store it.
- ETag lets a client ask “has this changed?” and get a tiny 304 Not Modified if not.
no-cachemeans “store but always check first”;no-storemeans “never store”.- Give fingerprinted static files long lifetimes, and HTML short or revalidated ones.
A product page loads a 300 KB JavaScript bundle, the same file on every visit. Without caching, every visitor downloads it every time, the server does the same work again and again, and pages feel slow on mobile networks. With the right headers, the browser keeps a copy and the server barely notices repeat visitors. Caching is one of the cheapest performance wins available, and one of the easiest to get subtly wrong.
We wrote a tiny Python HTTP server that sends Cache-Control and ETag headers, and a client that requests the same resource twice. The headers and status codes below come from that run. Browser and CDN behaviour is described from the HTTP caching specification, RFC 9111, and vendor documentation.
Why caching matters
- Speed: a cached response loads instantly, with no network round trip.
- Lower load: fewer requests reach your servers and databases.
- Lower cost: less bandwidth and fewer compute resources.
- Resilience: CDNs can keep serving cached content during short outages.
Where responses are cached
| Cache | Shared? | Example |
|---|---|---|
| Browser cache | Private, one user | Chrome, Firefox, Safari |
| CDN / edge cache | Shared, many users | Cloudflare, Fastly, CloudFront |
| Reverse proxy | Shared | NGINX, Varnish, LiteSpeed |
| Application cache | Inside your app | Redis, in-memory caches |
HTTP headers control the first three. The difference between private and shared caches matters a lot: a page containing one user’s account details must never be stored in a shared cache and served to someone else.
Freshness and validation
HTTP caching rests on two ideas:
- Freshness: for a set time, a cached copy is used without contacting the server at all.
- Validation: after that time, the cache asks the server whether its copy is still current. If yes, the server answers with a tiny “not modified”; if no, it sends the new version.
Cache-Control controls freshness. ETag and Last-Modified make validation possible.
Cache-Control directives
| Directive | Meaning |
|---|---|
max-age=60 |
Fresh for 60 seconds |
s-maxage=300 |
Freshness for shared caches like CDNs only |
public |
Any cache may store it |
private |
Only the user’s browser may store it |
no-cache |
May be stored, but must be revalidated before every use |
no-store |
Must not be stored anywhere |
immutable |
Will never change while fresh; skip revalidation |
must-revalidate |
Once stale, must not be used without revalidation |
stale-while-revalidate=30 |
May serve a stale copy while refreshing in the background |
Directives combine with commas, such as Cache-Control: public, max-age=31536000, immutable. The older Expires header gives an absolute date; when both are present, Cache-Control wins.
no-cache vs no-store
These names cause endless confusion. no-cache does not mean “do not cache”. It means the cache may keep a copy but must check with the server before using it, which is cheap with an ETag and ideal for HTML that changes often. no-store is the real “never save this” setting, used for sensitive responses such as banking pages or one-time tokens. Using no-store everywhere “to be safe” throws away all the benefits of revalidation.
ETag and 304 Not Modified
An ETag is an identifier for a specific version of a resource, often a hash of its contents. The flow:
- The server sends the response with
ETag: "abc123". - Later, the client sends
If-None-Match: "abc123". - If the resource is unchanged, the server replies
304 Not Modifiedwith no body. The client reuses its copy. - If it changed, the server sends
200 OKwith the new body and a new ETag.
Our live test
first: 200 {'Cache-Control': 'max-age=60', 'ETag': '"f156315ecd341917"', 'Content-Length': '29'}
revalidate: 304 body bytes: 0
etag changes with content: True
server counts: {'full': 1, '304': 1}
The first request returned the full body, 29 bytes in our tiny example, with a 60-second lifetime and an ETag made from a hash of the content. When the client sent that ETag back in If-None-Match, the server replied 304 with zero body bytes. Changing the content produced a different ETag, so clients would receive the new version. For a real page or API response, the saving is the whole body, often hundreds of kilobytes. Status codes like 304 are covered in our HTTP status codes cheat sheet.
Tip: A 304 still costs a round trip. For files that truly never change, such as versioned bundles, a long max-age with immutable avoids even that request. Revalidation is for content that may change; long freshness is for content that cannot.
Last-Modified
Last-Modified is an older validator based on time: the client sends If-Modified-Since, and the server answers 304 if nothing changed since then. It works, but its precision is one second and it can be fooled by files whose timestamps change without content changes. ETags are more precise. When a response has neither Cache-Control freshness nor Expires, browsers may guess a lifetime from Last-Modified, called heuristic caching, which can cause surprising staleness. Always send explicit headers.
Cache busting
How do you give a file a one-year lifetime and still release updates? Change its URL. Build tools add a hash of the content to the file name, such as app.3f9a2c.js. Each release produces new names; the HTML references the new names; old files can stay cached forever because nothing asks for them any more. This is why the HTML itself must not be cached for long: it is the map that points to the current versions.
A practical strategy
| Resource | Suggested header |
|---|---|
| Fingerprinted JS, CSS, fonts, images | public, max-age=31536000, immutable |
| HTML pages | no-cache with an ETag, or a short max-age |
| Public API data that changes slowly | public, max-age=60, stale-while-revalidate=30 with an ETag |
| Logged-in user data | private, no-cache |
| Sensitive data, tokens | no-store |
Treat these as starting points and adjust lifetimes to how often your content changes and how stale it may safely be. This site, for example, runs a page cache plugin that purges cached pages when a post is updated, the same principle applied at the server.
Vary and personalised content
The Vary header tells caches that a response depends on certain request headers. Vary: Accept-Encoding keeps compressed and uncompressed versions separate; Vary: Accept-Language separates languages. Avoid varying on headers with many values, such as Cookie or User-Agent, which fragments the cache until almost nothing is reused. The serious risk is the opposite mistake: caching a personalised response in a shared cache without private, so one user’s data is shown to another. Our REST API best practices discuss safe defaults for APIs.
Testing caching
- Inspect headers with your browser’s network panel or
curl -I https://example.com/. - Check the “size” column for “(disk cache)” or “(memory cache)” on repeat loads.
- Send
If-None-Matchwith the ETag and confirm a 304, as in our test. - Deploy a change and confirm users actually receive it: stale content after a release is the classic caching bug.
- Log in as two different users and confirm no personal data leaks through a shared cache.
Caching also affects load testing: a test that hits cached URLs measures the cache, not your application.
Caching API responses
APIs benefit from the same headers as web pages, but the defaults deserve more thought:
- GET requests for public, slow-changing data, such as product catalogues or reference lists, are good candidates for a short
max-ageplus an ETag. - Per-user data should be
private, so CDNs never share it, and usually revalidated. - POST, PUT and DELETE are not cached by default; after a change, make sure related cached GET responses are refreshed or purged.
- ETags enable safe updates too. Clients can send
If-Matchwith the ETag when updating a resource, and the server rejects the change with412 Precondition Failedif someone else modified it first, preventing lost updates.
Document your caching behaviour in the API reference, so client developers know how fresh the data is and when to expect 304 responses.
Final thoughts
HTTP caching comes down to two questions for every response: how long may it be reused without asking, and how can a cache check whether it is still current? Answer the first with Cache-Control and the second with an ETag. Cache fingerprinted assets for a year, revalidate HTML, keep personal data private, and test that new releases actually reach users.
Frequently asked questions
What does Cache-Control: no-cache mean?
The response may be stored, but the cache must check with the server before reusing it. It does not mean “do not cache”; that is no-store.
What is an ETag?
An identifier for a specific version of a resource. Clients send it back in If-None-Match, and the server replies 304 Not Modified if the resource has not changed.
What does 304 Not Modified mean?
The client’s cached copy is still current, so the server sends no body. In our test the 304 response carried zero body bytes.
How long should I cache static files?
For files with a content hash in the name, a year with immutable is common. Without fingerprinting, use a shorter max-age with revalidation.
Why do users still see old content after a deploy?
HTML or assets were cached too long without changing URLs. Use fingerprinted asset names, revalidate HTML, and purge CDN caches on release.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.