You deployed. The new version is live on *.web.app. But your friend opens the custom domain and still sees yesterday’s headline. The issue is almost always cache — Firebase Hosting’s CDN, the browser, or both. The fix is one cache-header change in firebase.json and a redeploy.
Background
Firebase Hosting caches assets at the CDN edge and tells browsers to cache them too. The default for files without explicit Cache-Control is roughly one hour. That is fine for images and JS, terrible for HTML — visitors do not see your update until the hour expires. The right fix is per-file-type cache headers in firebase.json, plus a service worker discipline if you ship one.
How to tell
- Deploy succeeded. New content visible only in incognito.
- Mobile sees new content, desktop sees old (or vice versa) — different cache states.
- Custom domain serves old content,
*.web.appserves new — edge cache differs by host. - An old service worker keeps re-fetching cached assets.
curl -Iagainst the HTML URL shows nocache-controlheader (default fallback applies).
Quick verdict
Set Cache-Control: no-cache, max-age=0 for HTML and max-age=31536000, immutable for hashed static assets. Then ship a deploy — the next request fetches fresh.
Before you start
- Confirm the build output directory matches
firebase.json(distfor Astro,outfor Next static export). - Have
curlavailable to verify headers — not just devtools, which adds its own behavior. - If you ship a service worker, locate its source file before deploying any cache changes.
Step by step
- Diagnose first. Confirm the cache state on the slow path:
curl -sI https://yourdomain.com/ | grep -iE 'cache-control|age|x-cache'
# typical bad state:
# age: 1840
# x-cache: HIT
# (no cache-control or 'public, max-age=3600')
- Edit
firebase.json— full working config:
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"cleanUrls": true,
"trailingSlash": true,
"headers": [
{
"source": "**/*.html",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, max-age=0" }
]
},
{
"source": "/_astro/**",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "**/*.@(js|css|woff2)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "**/*.@(jpg|jpeg|png|webp|avif|svg)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=2592000" }
]
},
{
"source": "/sitemap*.xml",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=3600" }
]
}
]
}
}
-
HTML uses
no-cache, max-age=0, notno-store.no-cachemeans “store but revalidate every time” — the browser still benefits from local copy but always checks freshness.no-storeforces a full re-download, which is wasteful. -
Hashed static assets get
immutable. Only safe when the filename changes on every content change. Astro’s/_astro/**and Vite’s hashed outputs both qualify. Plainapp.jsdoes not. -
Deploy and verify the headers at the edge:
npm run build
firebase deploy --only hosting
# 30 seconds later:
curl -sI https://yourdomain.com/ | grep -i cache-control
# cache-control: no-cache, max-age=0
curl -sI https://yourdomain.com/_astro/index.abc123.css | grep -i cache-control
# cache-control: public, max-age=31536000, immutable
-
For a stuck browser, hard-reload (Cmd+Shift+R / Ctrl+Shift+R) once to flush local cache. That clears the local copy; the
no-cacheheader keeps it correct from then on. -
If you ship a service worker, ship a new SW version on every deploy and call
skipWaiting()to avoid week-long stale caches:
// public/sw.js
const VERSION = 'v2026-05-22-1'; // bump on every deploy
self.addEventListener('install', (e) => {
self.skipWaiting(); // activate immediately
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => !k.includes(VERSION)).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
- If the CDN cache is still stale on a specific path, force a miss by changing the URL slightly (publish at a versioned path then redirect), or roll the release back and forward:
firebase hosting:releases:list
firebase hosting:clone yourproject:live yourproject:live --version <prev>
firebase deploy --only hosting # forward again, fresh
Implementation checklist
- HTML, hashed assets, images, and sitemap have explicit
Cache-Controlheaders infirebase.json. curl -sIverifies headers after every deploy.- Service worker (if any) bumps version + calls
skipWaiting()per deploy. - Build outputs hashed filenames for
/_astro/**or equivalent.
After-launch verification
curl -sIfrom a different network or via a free remote tool (e.g. WebPageTest) — confirms edge cache is updated, not just your local node.- Open the site in incognito and a logged-out browser; both should see the new version immediately.
- Service worker registration in DevTools → Application → Service Workers shows the new version active.
Common pitfalls
- Leaving HTML on the default ~1-hour cache and chasing “ghost” stale pages for an hour after every deploy.
- Setting
immutableon HTML — browsers will never revalidate, even after a hard reload. - Cache headers in your framework conflict with
firebase.json— last writer wins; check the actual response in DevTools orcurl. - A buggy service worker serving the old bundle indefinitely after deploy.
- CDN cache holding stale on a custom domain after a config change — wait 5 minutes or roll the release to force re-edge.
- Using
no-storeon HTML whenno-cacheis intended —no-storedisables back/forward cache too.
FAQ
- How do I force a refresh for everyone right now?: Change the affected HTML and redeploy. Edge cache invalidates on the new version. Visitors with a long browser cache need to revalidate, which
no-cacheensures. - Does Firebase Hosting purge CDN cache on deploy?: Yes for the affected paths. New content propagates to edges within seconds for most regions.
- Can I set different cache for different paths?: Yes. The
headersarray supports multiplesourcepatterns, each with their own headers — first match wins for the same header name. - My page is fresh but my CSS is still old — why?: Your CSS filename probably is not hashed. Either hash it via your build tool, or shorten the CSS cache time.
- Will
no-cachehurt performance?: Minimally for static HTML. The browser still uses its local copy via conditional GET (If-None-Match) — the network round-trip is small and frequent revalidation is the price of instant updates.