Deploy Succeeded But Page Still Shows Old Content

Vercel / Firebase says deploy ok, but visitors see yesterday's version.

The Vercel / Netlify / Firebase dashboard shows a green check, the commit SHA matches, yet hitting refresh still returns yesterday’s HTML. This is one of the most disorienting classes of deploy bug because the platform is telling you the truth — the artifact really did ship — but somewhere between origin and browser, a cache is shadowing it. Below are the five hit-rate-ranked causes and a 10-minute diagnostic path that isolates them in order.

Common causes

Ordered by hit rate, highest first.

1. Service worker is intercepting the request with cached HTML

PWA templates (Workbox, Next PWA, Vite PWA) register a service worker that caches HTML and assets in Cache Storage. On the next visit, the SW calls respondWith(cache.match()) and never hits the network — so your deploy is irrelevant until the SW is unregistered.

// Typical "cache-first" handler — most common offender
self.addEventListener("fetch", e => {
  e.respondWith(caches.match(e.request).then(r => r || fetch(e.request)));
});

How to spot it: DevTools → Application → Service Workers shows “Activated and is running”; the Network panel shows the page request with (ServiceWorker) listed as the source.

2. CDN edge cache wasn’t invalidated

Vercel, Cloudflare, and Firebase all cache HTML at edge POPs. If you pushed straight to S3 or changed your origin without firing a purge, the edge keeps serving the previous version until its TTL expires.

How to spot it: curl -I https://yourdomain.com/your-page returns x-vercel-cache: HIT / cf-cache-status: HIT / age: 3600.

3. Browser is long-caching the HTML

If a Cache-Control: public, max-age=31536000 header ever made it onto your HTML response (custom headers block in vercel.json, a Next.js headers() rule, or a previous deploy), browsers won’t revalidate for a year.

How to spot it: DevTools → Network → click the HTML request → Response Headers; any Cache-Control: max-age= greater than 0 on a non-asset response is suspect.

4. Build used the wrong commit / cached output

CI triggered, but the build step reused a cached dist/, or you pushed to the wrong branch. Vercel binds “Production” to main by default — pushes to feature branches create previews only, but the dashboard still says “Ready”.

How to spot it: The deploy detail page shows a commit SHA. Compare it to git rev-parse HEAD; if they differ, the build wasn’t from your latest code.

5. Domain still points at an old origin

The domain is attached to both Vercel and Cloudflare Pages, or an old A record from a previous host (GitHub Pages, Netlify, an EC2 box) was never cleaned up. Some users land on the old origin.

How to spot it: dig +short yourdomain.com returns an IP; cross-check ownership with whois or ipinfo.io and confirm it belongs to your current host.

Shortest path to fix

Work from client outward to origin. The first two steps resolve most cases.

Step 1: Isolate the client cache in incognito

Open the page in an incognito window with DevTools → Network → “Disable cache” enabled.

What you seeConclusion
New versionCache lives in your normal profile (browser or SW) → Step 2
Still old versionNot a client cache — it’s the edge or origin → Step 3

A hard refresh in the normal window (Cmd/Ctrl+Shift+R) is a useful sanity check too.

Step 2: Unregister the service worker + clear site data

DevTools → Application → Service Workers → “Unregister” on every entry. Then Application → Storage → “Clear site data” and reload.

To push a one-time “self-destruct” SW so existing users auto-clean on their next visit:

// public/sw.js — ship once; on activation it nukes caches and unregisters itself
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
  const keys = await caches.keys();
  await Promise.all(keys.map(k => caches.delete(k)));
  await self.registration.unregister();
  const clients = await self.clients.matchAll();
  clients.forEach(c => c.navigate(c.url));
});

Step 3: Manually purge the edge cache

Pick the one for your host:

# Vercel — redeploy without build cache
vercel --prod --force

# Cloudflare — dashboard: Caching → Configuration → Purge Everything
# Or via API:
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

# Firebase Hosting — redeploy triggers CDN invalidation
firebase deploy --only hosting --force

Verify with curl -I:

curl -sI https://yourdomain.com/your-page | grep -iE 'cache|age'
# Expect: x-vercel-cache: MISS  or  cf-cache-status: MISS  with age: 0

Step 4: Confirm the build actually used your latest commit

git rev-parse HEAD
# Compare against the "Source" SHA on the Vercel / Netlify deploy detail page

If they differ, push again or click “Redeploy” in the dashboard with “Use existing Build Cache” unchecked.

Step 5: Make sure DNS isn’t pointing at an old origin

dig +short yourdomain.com
# Paste the IP into https://ipinfo.io/ — confirm the org is your current host

Stray A records (e.g. GitHub Pages’ 185.199.x.x) need to go — keep only the records your current host recommends.

Prevention

  • Use content-hashed asset filenames (Vite / Next / Astro do this by default) and keep HTML on a short TTL (s-maxage=0, must-revalidate).
  • Service workers: use “network-first for navigation, cache-first for hashed assets,” and call self.skipWaiting() in install.
  • Write the deploy’s commit SHA into /version.json and have the frontend compare on load; surface a “new version available, refresh” toast.
  • Add a CI smoke test that `curl’s the homepage post-deploy and asserts the response contains this commit’s SHA.
  • One domain, one origin. Audit stray A / CNAME records with dig quarterly.

Tags: #Hosting #Debug #Troubleshooting