Browser Caching for Hashed Assets
Content-hashed bundles are NOT long-cached by default -- the _headers immutable rule, 103 Early Hints, and why full CSS inlining is the wrong fix
Workers Static Assets serves every asset with a platform default of max-age=0 -- including content-hashed bundles like styles-<hash>.css whose URLs can never serve different content. The result: in practice, every navigation pays a conditional request for files that should be free. This page covers the one-rule _headers fix, how to verify it, the 103 Early Hints complement for cold-cache visits, and why answering the Lighthouse "inline your CSS" suggestion -- the audit that often surfaces this problem -- by inlining the whole bundle is the wrong fix.
The Default: Nothing Is Long-Cached
Workers Static Assets serves assets with this platform default (applied when the request carries no Authorization or Range header; responses also get a content-derived ETag):
cache-control: public, max-age=0, must-revalidateThis applies to content-hashed bundles too -- styles-<hash>.css, islands-<hash>.js, anything your build fingerprints. max-age=0, must-revalidate tells the browser its cached copy is stale immediately, so whenever it wants to reuse that copy -- in practice, on every navigation -- it first sends a conditional request (If-None-Match: <etag>) and gets a 304 Not Modified back. For a fingerprinted URL that by construction can never change its content, that round trip is pure waste.
This is easy to miss because everything looks healthy:
cf-cache-status: HIT-- but that only says Cloudflare's edge cache has the file. It says nothing about whether the browser can reuse its copy without asking.The 304s are cheap -- tens of milliseconds each. No single request looks like a problem; the tax only shows up as a per-navigation constant multiplied by every asset and every page view.
Edge cache and browser cache are separate problems
cf-cache-status describes the path between Cloudflare's edge and your origin (here, the asset store). cache-control governs the path between the browser and the edge. A site can have a perfect edge hit rate while forcing every visitor to revalidate every asset on every click -- that is exactly the default behavior described here.
The Fix: One _headers Rule
Add a rule for your hashed-asset directory to the _headers file at the root of your asset directory (natively supported by Workers Static Assets, same as Pages):
/assets/*
Cache-Control: public, max-age=31536000, immutable Adapt the path to wherever your build writes fingerprinted output (for example / for a default Astro setup). Two placement details: the _headers file must end up inside the deployed asset directory (your build output), and it only affects responses served by the asset layer -- responses generated by your Worker code set their own headers.
This is the classic safe caching pattern for fingerprinted builds:
Hashed filenames change on every build. A new deploy produces
styles-<newhash>.css, so a year-longmax-agecan never serve stale styles -- the old URL simply stops being referenced.HTML stays
no-cache. Pages keep the platform default, so every navigation fetches fresh HTML that references the current hashes.immutablesuppresses even reload revalidation. On a normal reload, browsers revalidate cached assets despite a longmax-age;immutabletells them not to bother.
After the fix, repeat navigations serve CSS/JS straight from the browser's disk or memory cache with zero requests for the hashed bundles.
Verifying the Behavior
Check what production actually serves -- before and after the deploy:
# 1. What cache-control does the bundle get?
curl -sv -o /dev/null https://<site>/assets/<bundle> 2>&1 | grep -iE 'cache-control|etag|cf-cache-status'
# 2. Reproduce the 304-per-navigation tax (before the fix):
# take the etag from step 1 and send it back
curl -sv -o /dev/null -H 'If-None-Match: <etag>' https://<site>/assets/<bundle>
# -> HTTP/2 304 before the fix; after the fix the browser never sends this requestThen confirm in the browser: open DevTools -> Network, load a page, navigate to a second page. Before the fix, each hashed asset shows a 304 request per navigation. After the fix, they show "(disk cache)" or "(memory cache)" with no request at all.
103 Early Hints for Cold-Cache Visits
The immutable rule only helps repeat views -- a visitor with a cold cache still discovers the CSS bundle late, after downloading and parsing the HTML. Cloudflare's 103 Early Hints closes that gap: while the actual response is being produced, Cloudflare sends an interim 103 response carrying preload hints, so the browser starts fetching the bundle before the HTML arrives.
Setup is two pieces:
Enable Early Hints on the zone -- dashboard: Speed -> Settings -> Content Optimization.
Emit a
Linkheader for the critical asset, e.g. via_headers:
/
Link: </assets/<bundle>>; rel=preload; as=style Note the mechanics: Cloudflare learns eligible Link headers from your responses and caches them per URL, then emits the 103 interim response on later requests for that URL. The very first request for a URL Cloudflare hasn't seen yet gets no hint -- so this helps first-time visitors once the zone's Early Hints cache for the page is warm, and hints may also be skipped when the final response arrives fast enough.
Gotchas:
Zone-level setting. It applies to your custom domain's zone -- a bare
*.workers.devhost doesn't get it.HTTP/2 and HTTP/3 only. Browsers ignore Early Hints over HTTP/1.1.
The hashed URL changes every deploy. The
Linkheader references a fingerprinted filename, so the rule must be injected into_headersat build time, not hand-written.
Why Full CSS Inlining Is the Wrong Fix
This whole investigation typically starts with a Lighthouse suggestion: "Eliminate render-blocking resources -- consider inlining critical CSS." Taken literally for a shared bundle, that advice makes an MPA slower:
The inlining budget is small. web.dev's guidance is to inline critical above-the-fold CSS only, sized so the whole above-the-fold payload fits the first round trip -- roughly 14 KB compressed -- not a whole bundle.
Inlining destroys cross-page caching. An external bundle is downloaded once and reused on every page; inlined CSS is re-downloaded inside every HTML document. For a multi-page site with a shared bundle (~39 KB gzip in the real-world case below), that is ~39 KB added to every single page view, forever.
Lighthouse models a different audience. Its default estimates simulate slow 4G (150 ms RTT), which may not represent an edge-served site's real visitors. When the bundle comes from a Cloudflare edge a few tens of milliseconds away -- and, after the fix above, from browser cache on repeat views -- the modeled savings mostly evaporate for that audience.
To be clear about what fixes what: the immutable rule improves real repeat navigations, but it will not clear the Lighthouse warning -- Lighthouse audits a cold load with an empty cache, so browser caching is invisible to it. If the audit itself matters to you, inlining a small critical subset within the ~14 KB budget is the legitimate lever. What is not legitimate is inlining the whole bundle to satisfy a cold-load metric at the cost of making every real navigation heavier. For an edge-served MPA, the _headers immutable rule plus optional Early Hints is what actual visitors feel.
Real-World Numbers
Case data from takazudomodular.com (2026-07):
~780-page static MPA, one shared fingerprinted CSS bundle (269 KB raw / 39 KB gzip), served by Workers Static Assets via a production Worker.
Live production served the hashed bundle with
public, max-age=0, must-revalidate(weak etag,cf-cache-status: HIT) -- the site's_headershad rules for other paths but none for/, so the platform default applied.assets/ * Measured warm-CDN CSS fetch (single location, small-sample
curltimings over HTTP/2): ~60-65 ms including connection setup on a cold connection, ~25-30 ms marginal on an already-open connection. Indicative rather than rigorous -- but that is the shape of the 304-per-navigation tax the immutable rule removes, on every page view, for every visitor.
The lesson generalizes: if your _headers file exists but only covers some paths, the platform default silently applies to everything else -- including the assets that would benefit most from long caching.