Firebase Hosting cache & how to force fresh content after deploy

Deployed but visitors see the old page? Use this firebase.json cache-header config, curl diagnostic, and rollback to flush stale content in seconds.

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.app serves new — edge cache differs by host.
  • An old service worker keeps re-fetching cached assets.
  • curl -I against the HTML URL shows no cache-control header (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 (dist for Astro, out for Next static export).
  • Have curl available 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

  1. 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')
  1. 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" }
        ]
      }
    ]
  }
}
  1. HTML uses no-cache, max-age=0, not no-store. no-cache means “store but revalidate every time” — the browser still benefits from local copy but always checks freshness. no-store forces a full re-download, which is wasteful.

  2. 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. Plain app.js does not.

  3. 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
  1. For a stuck browser, hard-reload (Cmd+Shift+R / Ctrl+Shift+R) once to flush local cache. That clears the local copy; the no-cache header keeps it correct from then on.

  2. 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();
});
  1. 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-Control headers in firebase.json.
  • curl -sI verifies 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 -sI from 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 immutable on 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 or curl.
  • 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-store on HTML when no-cache is intended — no-store disables 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-cache ensures.
  • 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 headers array supports multiple source patterns, 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-cache hurt 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.

Tags: #Indie dev #Firebase #Hosting #Troubleshooting