Deploy Succeeded But Page Shows Old Content: Fix

Vercel / Firebase / Cloudflare say the deploy shipped, but visitors still see yesterday's HTML. The 5 ranked causes and a 10-minute client-to-origin diagnostic.

The dashboard shows a green check, the commit SHA matches, yet refresh still returns yesterday’s HTML. The platform is telling you the truth — the artifact really did ship — but somewhere between origin and browser a cache is shadowing it.

Fastest fix (resolves ~70% of cases): open the page in an incognito window. If the new version appears there, it is a client-side cache (browser or service worker), not your deploy. Clear it in your normal profile: DevTools → Application → Service Workers → Unregister every entry, then Application → Storage → Clear site data, then reload. If incognito also shows the old version, the cache is at the edge or origin — jump to Step 3.

Below are the five causes ranked by hit rate, then a 10-minute diagnostic path that isolates them from the client outward.

Common causes

Ordered by hit rate, highest first.

1. A service worker is intercepting the request with cached HTML

PWA templates (Workbox, next-pwa, vite-plugin-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 that SW is unregistered. This is by far the most common cause because it bypasses both the edge and the origin entirely.

// Typical "cache-first" handler — the 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”; in the Network panel the page request lists (ServiceWorker) in the Size column as its source.

2. The CDN edge cache wasn’t invalidated

Vercel, Cloudflare, and Firebase all cache responses at edge POPs. If you pushed straight to object storage (S3, GCS) 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, or cf-cache-status: HIT, or a large age: value (e.g. age: 3600).

3. The browser is long-caching the HTML

If a Cache-Control: public, max-age=31536000 header ever landed on your HTML response (a custom headers block in vercel.json, a Next.js headers() rule, a firebase.json header glob, or a previous deploy), browsers will not revalidate for a year. As of June 2026, Vercel’s default for static HTML is Cache-Control: public, max-age=0, must-revalidate, so this only bites you when something overrode the default.

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

4. The build used the wrong commit or a cached output

CI triggered, but the build step reused a cached dist/, or you pushed to the wrong branch. Vercel binds Production to your production branch (main by default); pushes to other branches create Preview deployments only, but the dashboard still says “Ready” — easy to mistake for a production deploy.

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

5. The domain still points at an old origin

The domain is attached to two hosts at once (e.g. 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 — depending on which record resolves — land on the old origin.

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

Which bucket am I in?

SymptomMost likely causeGo to
New version in incognito, old in normal windowService worker / browser cache (1, 3)Step 2
Old version even in incognito; curl shows HIT / high ageEdge cache not purged (2)Step 3
curl shows MISS but HTML is still oldWrong commit built, or HTML long-cache header (3, 4)Step 4
Some users new, some users oldSplit DNS / old origin (5)Step 5

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. Incognito does not run your installed service worker or read your normal browser cache, so it shows what a fresh visitor gets.

What you seeConclusion
New versionThe cache 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, but note that a hard refresh does not bypass an active service worker — only unregistering it does.

Step 2: Unregister the service worker and clear site data

DevTools → Application → Service Workers → click 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, ship a worker that takes control immediately, deletes every cache, then unregisters itself:

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

Critical: serve /sw.js itself with Cache-Control: no-cache (or max-age=0). If the old buggy worker file is long-cached, the browser may never fetch the kill-switch worker and users stay stuck.

Step 3: Manually purge the edge cache

Pick the one for your host:

# Vercel — redeploy without the build cache
vercel --prod --force
#   (or set VERCEL_FORCE_NO_BUILD_CACHE=1 on the project to always skip it)

# Cloudflare — Dashboard: Caching → Configuration → Purge Everything
#   Or via API (note: Free plan is rate-limited to ~5 purges/min):
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 — a normal redeploy automatically clears the CDN cache.
#   There is NO "purge everything" button; redeploy is the invalidation.
firebase deploy --only hosting

Note on Firebase: --force on firebase deploy is not a cache flag — it skips confirmation and deletes Cloud Functions missing from your source. For Hosting you don’t need it; a plain firebase deploy --only hosting invalidates the CDN.

Verify with curl -I:

curl -sI https://yourdomain.com/your-page | grep -iE 'cache|age'
# Vercel:     expect  x-vercel-cache: MISS  with age: 0
# Cloudflare: first request after purge shows cf-cache-status: EXPIRED or MISS;
#             only a later request returns to HIT

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 and uncheck “Redeploy with existing Build Cache” (the exact label as of June 2026). On Vercel, also confirm the deployment is assigned to Production, not a Preview alias.

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

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

Stray A records (for example GitHub Pages’ 185.199.108-111.153) need to go — keep only the records your current host recommends. If you front the site with Cloudflare, also confirm the proxy (orange-cloud) is pointed at the right backend.

How to confirm it’s fixed

  1. curl -sI https://yourdomain.com/your-page returns MISS (or EXPIRED on the first Cloudflare hit) with low age:.
  2. The HTML response body contains a string unique to the new deploy (a changed heading, or a build SHA — see Prevention).
  3. A fresh incognito window shows the new version with no manual cache clearing.
  4. DevTools → Application → Service Workers shows either no worker, or the new worker as “Activated and is running” with no redundant waiting entry.

Prevention

  • Use content-hashed asset filenames (Vite, Next, and Astro do this by default) and keep HTML on a short TTL — Vercel’s public, max-age=0, must-revalidate default is correct; don’t override it.
  • Service workers: use “network-first for navigation, cache-first for hashed assets,” call self.skipWaiting() in install and clients.claim() in activate, and serve /sw.js with Cache-Control: no-cache.
  • Write the deploy’s commit SHA into /version.json and have the frontend compare it on load, surfacing a “new version available — refresh” toast.
  • Add a CI smoke test that curls the homepage after deploy and asserts the response contains this commit’s SHA.
  • One domain, one origin. Audit stray A / CNAME records with dig quarterly.

FAQ

The dashboard says “Ready” / green check — doesn’t that mean it’s live? Not necessarily for the URL you’re testing. A green check confirms the build succeeded and an artifact exists. On Vercel a push to a non-production branch produces a Preview deployment that also shows “Ready”; it is not promoted to your production domain. Check the Source SHA and that the deployment targets Production.

I cleared the browser cache and hit Cmd+Shift+R but still see the old page. A hard refresh does not bypass an active service worker. You must unregister it (DevTools → Application → Service Workers → Unregister) or test in incognito, which doesn’t run your installed worker.

How long until a CDN purge takes effect? Cloudflare’s “Purge Everything” propagates across data centers within seconds; the first request after a purge shows cf-cache-status: EXPIRED or MISS, then returns to HIT. Vercel’s edge updates on the new deployment immediately. Firebase clears its CDN automatically on each redeploy.

Why do only some visitors see the old version? That pattern points to split DNS or two origins (cause 5): different users resolve to different records, or you still have a stray A record from a previous host. Run dig +short and verify every returned IP belongs to your current host.

Is curl -I enough, or do I need a real browser? curl -I shows you the edge/origin cache state (x-vercel-cache, cf-cache-status, age) without any browser cache in the way, which is exactly why it’s the right tool for Step 3. Use a real (incognito) browser only to test the client-side layer in Step 1.

Tags: #Hosting #Debug #Troubleshooting