Cache-Control: no-cache vs no-store, with an ETag Example
Why does no-cache still return 304? Choose HTTP caching headers, follow a tested Python and curl example, and trace stale pages to the right layer.
You deploy a change, reload the page and still see the old version. Then you add Cache-Control: no-cache, inspect the next request and find 304 Not Modified. Has the browser ignored the instruction?
Usually, that result is exactly what the header allows. no-cache permits a response to be stored, but requires successful validation before it is reused for another HTTP request. no-store tells HTTP caches not to store that response. The names sound similar; the rules solve different problems. RFC 9111 defines both directives.
This guide is for developers configuring web pages, assets and read-only API responses. We will separate storage from freshness, choose a policy for each kind of response, and run a small example that produces 200, then 304, then 200 after the content changes. Browser history and service-worker storage need separate checks, which we will cover too.
If you only need to troubleshoot a site you visit, start with how to hard reload a stale page in Chrome. The rest of this article focuses on fixing the responses a site sends.
Storage, freshness and validation are separate decisions
A cache keeps a response so it can potentially serve it again. A browser's HTTP cache is a private cache, used for one user. A shared cache, such as a caching proxy or CDN, can serve multiple users. Here, “private” describes the cache's scope, not encryption or a separate account boundary inside a shared browser profile.
When choosing response headers, ask three questions: may the response be kept, who may keep it, and what must happen before it is reused? These common directives answer different parts of that decision.
| Response directive | What it controls |
|---|---|
|
| A stored response needs successful validation before reuse. |
|
| HTTP caches must not store this response for later reuse. |
|
| Shared caches must not store it; a private cache may. |
|
| Sets a freshness lifetime of 60 seconds. It is not a deletion timer. |
|
| Once the response is stale, successful validation is required before reuse. |
The table summarises unqualified directives: private and no-cache without a list of field names. The HTTP caching standard also defines those less common qualified forms.
Fresh means a stored response is still within its permitted freshness lifetime. It does not prove that the origin server has not changed. Stale means that lifetime has expired; the stored bytes might still be perfectly current. Validation resolves that question. MDN's Cache-Control reference explains these terms.
For example, max-age=60, must-revalidate can allow reuse while a response is fresh. must-revalidate does not force a server check on every request. Also, the response's age includes time spent in upstream caches: receiving it in your browser does not automatically restart a full 60-second allowance.
Choose a policy for the response you are serving
The following are starting points, not a configuration to paste over an entire site. Decide whether browser storage is acceptable and whether anyone other than the requesting user may receive the same representation.
| Intended behaviour | Example response header |
|---|---|
| A public document may be kept, but each reuse needs validation |
|
| A personalised response may be kept in a private cache, with validation before reuse |
|
| A sensitive response must not be retained in HTTP caches |
|
| A public, versioned asset is guaranteed not to change at that URL |
|
For personalised content, choose between private storage and no storage deliberately. A login cookie alone does not make a response private. private, no-cache still permits a browser to retain the response; it is unsuitable if that storage is itself unacceptable. Neither header replaces authentication, authorisation or HTTPS. See MDN's guidance on private responses.
The long-lived asset policy requires a matching deployment habit. An illustrative filename such as app.7c92d1.js should identify immutable content. When the content changes, publish a new filename and update the HTML reference. Do not overwrite the old URL and expect a one-year freshness policy to notice. Google's HTTP cache guide explains why versioned URLs and long freshness lifetimes belong together.
Treat the HTML document separately from the files it references. Freshly deployed JavaScript is irrelevant if a stored HTML document still points to the previous bundle. During a rollout, keeping the previous versioned assets available also helps documents that still reference them.
Changing the header is not a remote cache purge
Suppose you previously served a response with a long freshness lifetime and now change its origin header to no-store. A cache that can still use its stored response may never contact the origin to receive your new instruction.
no-store also does not erase older stored responses for the same URL. This limitation is documented in MDN's caching guide. For a managed CDN, use its documented invalidation mechanism where necessary. For versioned assets, change the URL and make sure the document referencing it updates. Treat each layer you operate as a separate place to verify.
How ETag turns a check into a small response
An ETag is a server-supplied identifier for a particular representation. It can come from a content hash or another reliable versioning method; HTTP does not require SHA-256. The value is quoted, for example ETag: "revision-42". A strong tag must change when the representation's bytes change. MDN's ETag reference describes the format and uses.
A client with a saved response can send that tag back in If-None-Match. For a conditional GET, a matching validator lets the server return 304 Not Modified. If the selected representation has changed, the server can return 200 OK with the new body and its new tag.
The 304 contains no response body. It tells a cache that its corresponding saved body is still usable, subject to the response's caching rules. It does not mean “no request happened”: validation involved an HTTP exchange. Nor does it promise that the server avoided a database query or other expensive work. The 304 specification defines the response and required metadata.
That gives us two different ways to save a transfer. A fresh, reusable response can avoid a network request altogether. A conditional request can avoid retransmitting an unchanged body. no-cache is compatible with the second behaviour.
The browser normally handles its HTTP-cache validators automatically. You do not need to add an ETag file to your frontend code. The web.dev explanation of request headers describes this automatic handling. The next example sends the condition explicitly so you can inspect it.
Reproduce the 200 → 304 → 200 sequence
Use a new, disposable folder, Python 3.9 or later, and curl 7.70 or later. We verified the conditional exchanges below on Linux with Python 3.12.14 and curl 8.5.0. The example has no third-party Python dependencies.
The commands use python3 and curl. On Windows, use the command that starts your installed Python 3, such as py -3, and curl.exe to select the curl executable. Check their versions first. The curl requirement covers using the ETag comparison and save options with the same filename, a combination fixed in curl 7.70.0.
Save this as cache_demo.py. It creates demo.txt beside the script if that file does not already exist, and serves only that exact path on the loopback address.
"""Local HTTP validator demonstration. Run in a new folder; stop with Ctrl+C."""
import hashlib
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
DOCUMENT = Path(__file__).with_name("demo.txt")
TAG = r'(?:W/)?"[\x21\x23-\x7e\x80-\xff]*"'
def matches(header, current):
if header.strip(" \t") == "*":
return True
pattern = rf"[ \t]*(?:{TAG})?(?:[ \t]*,[ \t]*(?:{TAG})?)*[ \t]*"
if not re.fullmatch(pattern, header):
return False
return any(tag.removeprefix("W/") == current
for tag in re.findall(TAG, header))
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self):
if self.path != "/demo.txt":
self.send_error(404)
return
try:
body = DOCUMENT.read_bytes()
except FileNotFoundError:
self.send_error(404)
return
etag = '"' + hashlib.sha256(body).hexdigest() + '"'
condition = ",".join(self.headers.get_all("If-None-Match", []))
unchanged = matches(condition, etag)
self.send_response(304 if unchanged else 200)
self.send_header("Cache-Control", "no-cache")
self.send_header("ETag", etag)
if not unchanged:
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if not unchanged:
self.wfile.write(body)
if __name__ == "__main__":
if not DOCUMENT.exists():
DOCUMENT.write_text("version one\n", encoding="utf-8")
print("Open http://127.0.0.1:8765/demo.txt; stop with Ctrl+C.", flush=True)
with ThreadingHTTPServer(("127.0.0.1", 8765), Handler) as server:
try:
server.serve_forever()
except KeyboardInterrupt:
pass This is a local protocol demonstration. It has no authentication, compression or production deployment configuration, and implements only GET. Python explicitly says http.server is not recommended for production. In a real application, use your server or framework's conditional-response support and check its actual output.
The script reads the file once, hashes those same bytes and sends them if needed. Its comparison helper handles weak tags, tag lists and *; If-None-Match uses weak comparison for this conditional GET. It is not implementing every HTTP precondition. The If-None-Match specification supplies the complete rules.
1. Fetch the first version and save its tag
Start the server:
python3 cache_demo.py Leave that terminal running. Open a second terminal in the same folder and run:
curl -i --etag-save demo.etag http://127.0.0.1:8765/demo.txt You should see 200 OK, Cache-Control: no-cache, a quoted ETag, and version one in the body. The -i option includes response headers in the output. The tag is also saved to demo.etag.
If the server reports that port 8765 is already in use, choose another unused local port in the script and all three request commands. If it cannot start, resolve that error before treating a curl connection failure as a caching problem.
2. Request the unchanged version conditionally
curl -i --etag-compare demo.etag http://127.0.0.1:8765/demo.txt Expect 304 Not Modified, the same ETag, and no version one body. The server also sends Cache-Control: no-cache on this response. Here, curl has explicitly sent the saved tag as If-None-Match; the curl manual documents that behaviour.
curl is not replaying a browser cache in this experiment. These commands contact the server each time. The tag file contains a validator, not a saved response body, so curl has no old page to display after the 304. The test demonstrates conditional HTTP exchanges, not a browser's storage policy or a CDN's behaviour.
3. Change the file without changing its URL
In a text editor, change demo.txt from version one to version two and save it. Keep the server running. Then request the same URL using the old tag, while saving whichever tag comes back:
curl -i --etag-compare demo.etag --etag-save demo.etag http://127.0.0.1:8765/demo.txt Expect 200 OK, version two, and a different ETag. Run that same command once more without editing the file: it should return 304 again. The sequence observed in our local check was:
| Request | File at the server | Result |
|---|---|---|
| First fetch, no saved validator |
|
|
| Send the first ETag |
|
|
| Send the first ETag after editing |
|
|
| Send the newly saved ETag |
|
|
Keep ETag on the 304 response. The curl save option creates an empty tag file if the response supplies no ETag, as the manual explains. In this example, Python's response helper adds Date; the handler adds the tag and caching policy on both successful response paths.
Stop the server with Ctrl+C in its terminal when finished. The files remain in your disposable folder; remove that folder when you no longer need the example.
If the deployed site still serves the wrong version
Start with one failing resource, identified by its complete URL. Open Chrome DevTools, select Network, reproduce the problem and inspect that request's headers and response. Leave Disable cache unchecked for the initial observation. Then use it as a separate comparison: Chrome documents that this option disables the browser cache, so leaving it enabled throughout can hide the behaviour visitors experience.
Record the request method, If-None-Match if present, response status, Cache-Control, ETag, and the actual body or asset version. Inspect the public deployment, not only the development server. A reverse proxy or CDN may be the layer answering the request.
| Observation | What to investigate next |
|---|---|
| No network transfer for the resource | Was the saved response still fresh, or did another cache mechanism supply it? |
|
| Check the current representation and validator at the responding layer. Does the tag change with the bytes? |
|
| Check which deployment, upstream or shared cache supplied that body. Status 200 alone does not identify the origin. |
| New HTML, old script or stylesheet | Compare the exact asset URL in the HTML with the URL you deployed. |
Check whether the response varies by request
One URL can select different representations. If a server chooses content using Accept-Language, for example, Vary: Accept-Language tells a cache to account for that request header when matching a stored response. Keep the applicable Vary value consistent on 304 responses too. MDN's Vary reference explains this additional matching information.
A related case occurs when an API's allowed CORS origin changes with the request's Origin header. Our guide to CORS preflight, headers and credentials covers Vary: Origin and the browser checks involved. An ETag does not replace the need to select the right representation, and Vary is not a substitute for keeping personalised responses out of inappropriate shared caches.
HTTP headers do not control every kind of saved page
Two mechanisms can make a correct HTTP policy look ineffective if you inspect the wrong layer.
Service-worker Cache API storage is controlled by application code. MDN explicitly states that the Cache API does not honour HTTP caching headers. If a service worker answers with a response stored there, review its update, matching and deletion logic. Changing an origin's Cache-Control header alone does not rewrite that code or remove its entries.
Back/Forward can restore page state. A browser can restore a previous document from its back/forward cache, often called bfcache, rather than make the ordinary HTTP request you expected. Chrome also permits some no-store pages in bfcache under documented conditions. Its current implementation guidance is more specific than the blanket advice that no-store disables the Back button's cache.
Test normal navigation, reload and Back/Forward separately. For sensitive applications, also test logout and account changes; an HTTP-cache directive alone does not define the application's session behaviour.
Before changing another header, capture one complete observation: the URL requested, the layer that answered, the policy received, the validator sent and the version displayed. That record turns “the cache is broken” into a question you can resolve: the response was reused under its freshness policy, a validator wrongly matched, the server returned old content, or a different storage mechanism supplied the page.
Sources and further reading
- IETF: RFC 9111, HTTP Caching and RFC 9110, HTTP Semantics, June 2022.
- MDN: Cache-Control, HTTP caching, ETag and Vary.
- curl project: ETag options and conditional transfers.
- Google: HTTP cache guidance, Chrome Network tools and bfcache with no-store.
- Implementation boundaries: Python http.server and MDN Cache API.