Notes
Caching lessons from static sites
Static hosting is supposed to be the boring, safe choice — no server process to crash, no database to corrupt. The part that still bites is caching, because a caching bug does not show up as an error anywhere. The site keeps serving 200 responses the whole time; it is just serving the wrong version of itself to some visitors and not others, which is exactly the kind of bug that is easy to ship without noticing.
The header rule that missed the home page
Firebase Hosting configuration lets you set cache headers per path pattern in
firebase.json. A natural-looking first attempt is to set a short or no-cache
header specifically on HTML files, so content edits show up immediately, while letting
everything else use the default:
{
"hosting": {
"headers": [
{
"source": "**/*.html",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
}
]
}
}
That pattern looks like it should match every HTML page on the site, and for most of them it
does. It does not match the request path / itself. A glob like
**/*.html matches paths ending in .html; the root path the browser
actually requests for the home page has no .html suffix on it at all, cleanUrls
or not, so that rule silently skips exactly the one page that matters most and leaves it on
whatever the default caching behavior is.
The fix is to invert the order: set a catch-all no-cache rule on every path first, then add a second, more specific rule afterward that opts genuinely static assets — hashed CSS and JS — back into long, aggressive caching. Firebase applies the more specific matching rule when more than one matches a request, so the override works correctly as long as the catch-all comes first in the list:
{
"hosting": {
"headers": [
{
"source": "**",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
},
{
"source": "**/*.@(js|css)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
}
]
}
}
With ** matching every request including the bare root, there is no longer a
path that falls through to an unintended default.
The CSS file nobody knew was stale
The second version of the same underlying mistake showed up on FitCalc.
The stylesheet there was serving with a 24-hour Cache-Control header under a
fixed filename, styles.css. That is a reasonable-looking setting — a day of
caching is not aggressive — but it meant that anyone who had opened the site within the
previous 24 hours, including a link shared earlier that day, would keep seeing the old
design for up to a full day after a redesign had already been deployed and was visible to
everyone else. The bug was not that the cache lasted too long in the abstract; it was that
the filename never changed, so the browser had no signal that the content behind that
filename was now different.
The fix was to put a content hash into the filename itself —
styles.3f9a21c8.css instead of styles.css — generated from the
file's own contents at build time. Any edit to the CSS produces a new hash and therefore a
new filename, which the HTML references directly. Because the filename itself changes when
the content changes, that file can now be marked immutable and cached for a
full year without any staleness risk: a URL either has never been requested before, in which
case there is nothing to be stale, or it has, in which case its content by construction
cannot have changed since. This is the same pattern the override rule above relies on —
long caching is only safe once the filename is tied to the content it points at.
A service worker can reintroduce the same bug
Once an app is installable as a progressive web app, there is a second place the exact same
staleness problem can hide: the service worker's own cache strategy, sitting between the
browser and whatever headers the hosting layer sends. A cache-first service worker will
happily keep answering every request from its local cache indefinitely, regardless of what
Cache-Control says on the server, which produces the identical symptom as the
stale CSS file above — a visitor who installed the app once keeps seeing an old build for as
long as the cache entry survives, with no error and no indication anything is out of date.
The fix is the same instinct applied one layer deeper: default to a network-first strategy,
where the service worker always tries the network before falling back to its cache, and
treat the cache purely as an offline fallback rather than the primary source of truth. It
costs a little more latency on every request. It also means a shipped fix actually reaches
people who already installed the app, instead of sitting behind a cache that only the
server-side headers were ever designed to control.
Checklist
- Put a catch-all
**no-cache rule first infirebase.json, and let more specific rules override it afterward — never rely on**/*.htmlalone to cover the root path. - Give hashed, immutable caching only to files whose name changes when their content changes.
- Any file referenced by a fixed name — a stylesheet, a script, an icon — should either be no-cache or carry a content hash. There is no safe middle ground of "cache it for a while" on a fixed filename.
- Verify the actual response headers on a live deploy, not just the config file. A rule that looks correct on paper can still fail to match the request path it was written for.