A fingerprinted asset puts a digest of its bytes into its URL. Changing a stylesheet produces a different address, so a page can request the new representation without depending on every existing cache expiring simultaneously. The useful guarantee comes from the publishing process: once an address is released, its contents must remain consistent with that digest.
Names support a freshness contract
A build can calculate SHA-256 for each generated file and rewrite the HTML references from logical names to those addresses. Unchanged bytes retain their name; changed bytes normally produce a different name. A short digest introduces additional collision risk, so choose its length deliberately. Browsers do not recalculate a filename’s hash as an integrity check. HTTP cache keys also include request context and potentially Vary fields; naming a file does not replace HTTP caching rules.
For public fingerprinted assets, Cache-Control: public, max-age=31536000, immutable combines a freshness lifetime with a promise that the representation will not change during that lifetime. RFC 8246 discourages unnecessary conditional requests while it remains fresh. Expiration, eviction, user overrides, and unsupported directives still exist. The RFC recommends ignoring immutable outside an authenticated context such as HTTPS. Neither permanent storage nor unlimited trust follows from this header.
HTML needs a separate policy because it selects the current asset names. A short lifetime or suitable revalidation can expose a new release while old assets remain available. Publish referenced files before exposing their manifest or HTML. Retain earlier assets long enough for old pages and rollback references to work. Fingerprinting does not make a collection of files an atomic release.
The public directive is appropriate only for representations that may be shared. A personalized download does not become suitable for shared caching because its filename contains a digest. Authorization and confidentiality requirements still determine its response policy.
When it breaks
Overwriting an old fingerprinted path creates two incompatible realities: an existing browser may reuse its cached red stylesheet while a fresh client retrieves blue bytes at the same address. Purging one CDN cannot remove every private browser copy. A deployment check should compare actual artifact bytes with their recorded digest and reject this mutation before release.
Deleting old assets immediately causes another failure. A cached page can still reference yesterday’s script, and a rollback can restore HTML whose dependencies no longer exist. Keeping the source commit alone does not preserve these artifacts. Likewise, publishing HTML before its new files reach every serving location introduces transient missing resources despite correct hashes.
Check it quickly
Save the following as immutable.py and run python3 immutable.py with Python 3. It starts a loopback server, verifies the CSS digest and cache header, deliberately changes bytes behind the old URL, detects the mismatch, restores the original, and shuts down. Expect three successful contract flags and browser_cache_tested_by_cli false. This command checks real HTTP responses; it does not implement a browser cache.
from hashlib import sha256
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Event, Lock, Thread
from urllib.parse import urlsplit
import json
import sys
styles = {'one': b'body{background:rgb(127,29,29);color:white;font-family:sans-serif;margin:3rem}',
'two': b'body{background:rgb(30,64,175);color:white;font-family:sans-serif;margin:3rem}'}
paths = {name: '/assets/' + sha256(data).hexdigest() + '.css' for name, data in styles.items()}
state = {'tampered': False, 'requests': []}
lock = Lock()
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path not in ('/tamper', '/restore'):
self.send_error(404)
return
with lock:
state['tampered'] = self.path == '/tamper'
self.send_response(204)
self.end_headers()
def do_GET(self):
path = urlsplit(self.path).path
cache = 'no-store'
if path in ('/one', '/two'):
name = path[1:]
data = f'<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Immutable asset lab</title><link rel="icon" href="data:,"><link rel="stylesheet" href="{paths[name]}"></head><body><h1>Release {name}</h1><p>The background comes from the referenced CSS artifact.</p></body></html>'.encode()
kind = 'text/html; charset=utf-8'
elif path in paths.values():
with lock:
state['requests'].append(path)
data = styles['two'] if path == paths['two'] or state['tampered'] else styles['one']
kind, cache = 'text/css', 'public, max-age=31536000, immutable'
elif path == '/events':
with lock:
data = json.dumps(state).encode()
kind = 'application/json'
else:
self.send_error(404)
return
self.send_response(200)
self.send_header('Content-Type', kind)
self.send_header('Cache-Control', cache)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
origin = f'http://127.0.0.1:{server.server_port}'
if '--serve' in sys.argv:
print(json.dumps({'origin': origin, 'assets': paths}), flush=True)
try:
Event().wait()
except KeyboardInterrupt:
pass
else:
def request(method, path):
connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
try:
connection.request(method, path)
response = connection.getresponse()
return response.status, response.getheader('Cache-Control'), response.read()
finally:
connection.close()
status, cache, original = request('GET', paths['one'])
assert status == 200 and cache == 'public, max-age=31536000, immutable'
assert sha256(original).hexdigest() in paths['one']
assert request('POST', '/tamper')[0] == 204
changed = request('GET', paths['one'])[2]
assert sha256(changed).hexdigest() not in paths['one']
assert request('GET', paths['two'])[2] == styles['two']
assert request('POST', '/restore')[0] == 204
assert request('GET', paths['one'])[2] == original
print(json.dumps({'hash_name_contract_checked': True,
'changed_old_url_detected': True, 'original_restored': True,
'browser_cache_tested_by_cli': False}))
finally:
server.shutdown()
thread.join()
server.server_close()
For the separate browser experiment, run python3 immutable.py --serve and copy the printed origin into a terminal variable named ASSET_ORIGIN. With a fresh browser profile and normal caching enabled, visit its /one page. Then run curl -X POST "$ASSET_ORIGIN/tamper", navigate to /one?after=mutation, and finally visit /two. Inspect /events to count asset requests. Stop the server with Ctrl-C when finished.
In Chrome 152.0.7977.83, the initial page was red. After mutation, the second navigation stayed red and the old stylesheet had only one server request. The new fingerprinted URL produced blue and one request for the second stylesheet. This demonstrates cache separation and the danger of rewriting fresh resources. Ordinary max-age freshness is sufficient for the observed reuse; the experiment does not isolate immutable’s effect on reload validation, test HTTPS, or prove behavior across browsers. A previously cached new URL may also be reused, so a changed reference does not guarantee a network transfer every time.
Further reading
- Content-Type should describe the bytes you actually send
- Cache headers are a contract with two clocks
Sources
- RFC 8246: HTTP Immutable Responses, freshness lifetime and authenticated-context guidance, checked 2026-09-13.
- MDN: HTTP caching, versioned URLs, cache freshness and validation, checked 2026-09-13.