Changing an origin does not notify every cache that already holds its response. Invalidation is about controlling which stored copies remain eligible for reuse, not merely updating a file. A reliable release distinguishes expiration, validation, targeted removal, and choosing a new URL. These mechanisms have different scopes, and none remotely erases every copy a reader has downloaded.
Expiration is not a broadcast
A fresh response can remain usable after its source changes. Freshness is calculated from lifetime and current age, accounting for cache metadata and elapsed time; it is not simply a countdown from one origin Date header. Expiration makes direct reuse conditional on the applicable rules. It neither guarantees physical deletion nor automatically causes an idle cache to contact the origin.
Validators are consulted when a request requires validation, not pushed to clients when content changes. A correct ETag lets the origin confirm an unchanged representation or send its replacement. It cannot force a browser holding a still-fresh response to ask. Changing the origin to no-cache or no-store does not retroactively rewrite previously received caching instructions.
Identify the scope before invalidating
RFC 9111 requires a cache receiving a successful or redirect response to an unsafe method to invalidate the target URI. This affects caches on that request’s path; it is not global notification. Application database writes made through another route do not automatically invalidate every derived page, query variant, translated response, or search listing.
A CDN purge similarly controls the provider’s managed copies, not independent browser caches. Identify the affected host, paths, variants, and cache tags using the provider’s actual contract. Publish the replacement before invalidating, track completion, then compare response bodies as well as cache status. An accepted purge request alone proves neither propagation nor that the origin now serves the desired bytes.
When it breaks
Purging before the origin is updated can immediately repopulate an edge with the old response. An in-flight refill can also race with invalidation; its handling depends on the cache implementation. Repeating a broad purge is not evidence that either race is resolved. Prefer a narrowly identified object set, and account for the additional origin load when those objects become cold.
For static assets, publish a new content-fingerprinted filename before switching HTML or a manifest to reference it. Keep the old asset available while previously cached documents can still reference it. The new URL avoids confusion with the old cache entry, but readers must receive the updated document first. Updating only the asset filename without updating its referring document changes nothing for those readers.
Deletion for confidentiality is a different problem. Removing an origin object or purging a CDN cannot revoke bytes already downloaded, and a cache policy is not authorization. Prevent sensitive responses from entering inappropriate caches in the first place. For an urgent repair, define what “visible” means: a fresh visit, an existing tab, or every previously downloaded copy cannot share one guarantee.
Check it quickly
Use Python 3.10 or newer and a POSIX shell. The tested versions are Python 3.14.4, CacheControl 0.14.4, and Requests 2.34.2. Run the Python block with "$invalidation_lab/venv/bin/python" after installing the pinned dependencies. It starts one loopback HTTP server on an allocated port, uses two separate in-memory private caches, disables environment proxies, and closes its resources. Installation alone needs external network access.
invalidation_lab=$(mktemp -d)
python3 -m venv "$invalidation_lab/venv"
"$invalidation_lab/venv/bin/python" -m pip install CacheControl==0.14.4 requests==2.34.2
from collections import Counter
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
import requests
from cachecontrol import CacheControl
from cachecontrol.cache import DictCache
assets = {"/manual.txt": b"A"}
counts = Counter()
class Origin(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
counts[self.path] += 1
body = assets.get(self.path, b"missing")
status = 200 if self.path in assets else 404
self.send_response(status)
self.send_header("Cache-Control", "max-age=600" if status == 200 else "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
server = HTTPServer(("127.0.0.1", 0), Origin)
worker = Thread(target=server.serve_forever)
worker.start()
base = f"http://127.0.0.1:{server.server_port}"
caches = [DictCache(), DictCache()]
clients = []
try:
for cache in caches:
client = CacheControl(requests.Session(), cache=cache)
client.trust_env = False
clients.append(client)
def check(index, path, status, body, cached):
response = clients[index].get(base + path, timeout=3)
assert (response.status_code, response.content, response.from_cache) == (
status, body, cached
)
for index in range(2):
check(index, "/manual.txt", 200, b"A", False)
check(index, "/manual.txt", 200, b"A", True)
assert counts["/manual.txt"] == 2
print("warm: two independent caches hold A; origin requests=2")
assets["/manual.txt"] = b"B"
caches[0].delete(base + "/manual.txt")
check(0, "/manual.txt", 200, b"B", False)
check(1, "/manual.txt", 200, b"A", True)
assert counts["/manual.txt"] == 3
print("targeted invalidation: cache 0=B; cache 1=A")
check(1, "/manual.v2.txt", 404, b"missing", False)
assets["/manual.v2.txt"] = b"B"
for index in range(2):
check(index, "/manual.v2.txt", 200, b"B", False)
check(index, "/manual.v2.txt", 200, b"B", True)
assert counts["/manual.v2.txt"] == 3
check(1, "/manual.txt", 200, b"A", True)
print("new URL: 404 before publication, then both caches=B")
print("old URL: cache 1 still holds A")
finally:
for client in clients:
client.close()
server.shutdown()
worker.join()
server.server_close()
Both clients first receive A from the origin, then hit their own cache. After the origin changes to B, deleting only cache zero’s exact URL makes that client retrieve B; cache one still returns A without another origin request. The old URL has reached the origin three times, demonstrating scoped removal rather than global convergence.
The new filename initially returns 404 with no-store, deliberately exposing incorrect publication order. After publication, both clients obtain and cache B under that new URL; cache one still retains A at the original URL. This is a real HTTP and cache-storage experiment, not a CDN purge implementation or browser test. CacheControl is a private-cache library and must not be repurposed as a multi-user shared edge.
Further reading
- Cache headers are a contract with two clocks
- Content-Type should describe the bytes you actually send
Sources
- RFC 9111: invalidating stored responses, invalidation scope and unsafe methods, checked 2026-09-07.
- CloudFront: invalidation and versioned filenames, edge removal does not clear independent caches, checked 2026-09-07.
- CacheControl usage, sessions and separate cache stores, checked 2026-09-07.
- CacheControl security model, private-cache restrictions, checked 2026-09-07.