Content Site Still Serves Old Article After Rebuild (CDN Stale)

You shipped a rebuild but production still serves the old article. Diagnose which cache layer is stale — CDN edge, browser HTTP cache, service worker, or stale HTML referencing old asset hashes — and purge the right one.

You merged a content fix, the rebuild deployed cleanly, but visitors keep seeing the old version of the article. Refreshing does nothing. Incognito sometimes helps, sometimes does not. The build is correct on disk and the origin returns the new HTML, but a cache layer between your origin and the reader is still serving last week’s copy.

Fastest fix: run curl -sI https://yoursite.com/articles/x/ against the affected URL and look at one header. If you see cf-cache-status: HIT (or x-vercel-cache: HIT / STALE) with a non-zero age:, the CDN edge is stale — purge it from the dashboard or API (Step 1). If you see cf-cache-status: DYNAMIC (or MISS/BYPASS) the edge is NOT your problem — the stale copy is in the browser or a service worker, so hard-reload and check DevTools (Steps 2-3). Identify the layer before you start clicking buttons; purging the CDN when the browser is the culprit just wastes a deploy.

The layers, from reader back to origin:

browser disk/HTTP cache  ->  service worker  ->  CDN edge cache  ->  origin

Each layer holds its own copy with its own TTL, and a content rebuild does not automatically invalidate all of them. This article walks layer-by-layer diagnosis, the exact purge for each host, and how to set cache headers so it stops recurring.

One important nuance: most hosts auto-invalidate, Cloudflare proxy does not

Before you panic, know who actually caches your HTML (verified June 2026):

HostHTML cached at edge by default?Invalidated on deploy?
VercelYes (per-deployment key)Yes — every deploy gets a unique cache key, so old HTML is unreachable automatically
NetlifyYes (atomic deploy)Yes — every deploy invalidates the prior deploy’s cache context automatically
Cloudflare PagesNo — HTML/JSON are DYNAMIC (not cached) unless you add a Cache RuleN/A for HTML; assets are content-hashed
Cloudflare proxy in front of your own originOnly if you added a “Cache Everything” / “Eligible for cache” ruleNo — you must purge

So if you are on Vercel or Netlify and still see old HTML, the edge almost certainly already refreshed; jump straight to the browser/service-worker layers (Steps 2-3). The “stale CDN after deploy” problem overwhelmingly hits people running Cloudflare’s reverse proxy in front of their own origin (S3, a VPS, GitHub Pages) with a Cache Rule that caches HTML — there, nothing purges automatically.

Common causes

1. CDN edge cache TTL has not expired (Cloudflare proxy with a cache rule)

If you cache HTML at a Cloudflare reverse proxy with a TTL of, say, 4 hours and the rebuild happened 30 minutes ago, the edge keeps serving old HTML until the TTL elapses or you purge.

How to judge: curl -sI https://yoursite.com/articles/x/ | grep -iE 'age|cache-control|cf-cache-status'. A cf-cache-status: HIT with a non-zero age: confirms a live edge copy. age: shows how many seconds old that copy is.

2. Build succeeded but the deploy hook did not purge the CDN

You build in GitHub Actions, push to S3 (or your origin), but the post-deploy purge step is missing or silently failed. Origin has the new file; the Cloudflare edge has the old one.

How to judge: read the CI run log. Is there an explicit “Purge CDN cache” / “purge_cache” step, and did it return HTTP 200 with "success": true?

3. HTML references stale asset hashes

Astro and Next.js fingerprint assets like index.A1B2C3.js. If the cached HTML still points to old hashes, even a fresh asset fetch resolves the old bundle — or 404s.

How to judge: view source on the production page and compare the _astro/ hashes against your latest build’s dist/_astro/ filenames.

4. A service worker is caching aggressively

If a previous build registered a service worker (PWA, Workbox), it intercepts requests and serves from its own cache for days — surviving hard reloads and even surviving a CDN purge, because the request never leaves the browser.

How to judge: DevTools > Application > Service workers. Any registered worker can override everything below it.

5. Browser HTTP cache is holding the old HTML

Cache-Control: max-age=3600 on an HTML document means the browser will not even ask the server for an hour. The CDN is fine; the browser is the culprit.

How to judge: hard reload (Cmd+Shift+R on macOS / Ctrl+F5 on Windows). If that shows new content, the browser HTTP cache was the layer.

6. stale-while-revalidate is masking the issue

stale-while-revalidate returns the old copy instantly while fetching the new one in the background. The first visit after a deploy sees old, the next visit sees new — confusing if you only test once.

How to judge: load the URL twice in a row. If the second load shows new content, SWR was at play (on Vercel this surfaces as x-vercel-cache: STALE on the first hit).

Decision aid: which layer are you in?

Run the curl check, then walk this table top to bottom:

Symptom                                              ->  Stale layer            ->  Fix
cf-cache-status / x-vercel-cache: HIT, age > 0       ->  CDN edge               ->  Step 1 (purge)
cf-cache-status: DYNAMIC/MISS/BYPASS, still old      ->  browser or SW          ->  Steps 2-3
Hard reload (Cmd+Shift+R) shows new content          ->  browser HTTP cache     ->  Step 2 (lower max-age)
DevTools shows a registered service worker           ->  service worker         ->  Step 3 (unregister)
Old content even in fresh incognito + disable cache  ->  CDN edge (re-check)    ->  Step 1
HTML hashes != dist/_astro hashes                    ->  stale HTML at edge     ->  Step 4 (purge HTML routes)

Rule of thumb: if “DevTools Disable cache + hard reload + a brand-new incognito window” all still show old content, the stale copy lives at the CDN edge. Otherwise it is local to the browser.

Cf-Cache-Status / X-Vercel-Cache values, decoded

You will read these constantly during this fix:

  • HIT — served from the edge cache (Cloudflare/Vercel). A non-zero age: means it is a stored copy.
  • MISS — not in edge cache, fetched from origin this time. Expected on the first request after a purge.
  • EXPIRED / STALE — edge had a stale copy and is revalidating; Vercel labels the SWR case STALE.
  • REVALIDATED — the entry was explicitly invalidated and re-fetched in the foreground.
  • DYNAMIC (Cloudflare) — the response was not eligible for edge caching. For HTML on Cloudflare this is the default; it means the edge is not caching your page at all.
  • BYPASS — caching was skipped (e.g. a Set-Cookie, a no-store, or a non-cacheable status).

Before you start

  • Have the exact URL of one affected page (include the trailing slash if your routes use one).
  • Confirm the latest build deployed successfully (CI green, artifact pushed).
  • Note when the deploy completed, so you can compare it to age: from curl.
  • Know your host (Vercel / Netlify / Cloudflare Pages / Cloudflare proxy + own origin) — it determines whether a manual purge is even needed.

Information to collect

  • Output of curl -sI <url> including age:, cache-control:, and cf-cache-status: / x-vercel-cache: / x-nf-request-id:.
  • View source of the affected page: the JS/CSS asset hash filenames.
  • CDN provider and any Cache Rules / cache settings in the dashboard.
  • The CI / deploy log line showing whether a purge step ran and returned "success": true.
  • DevTools > Application > Service workers state.

Step-by-step fix

Step 1: Purge the CDN edge cache

Cloudflare (dashboard): Caching > Configuration > Purge Cache > “Purge by URL” for a surgical fix, or “Purge Everything” for the whole zone.

# Cloudflare API — purge specific URLs (max 100 URLs per request on Free/Pro/Business)
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 '{"files":["https://yoursite.com/articles/x/"]}'

# Or purge the whole zone (rate-limited to 5 requests/minute on Free, as of June 2026)
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}'

A successful response contains "success": true. The API token needs the Zone > Cache Purge > Purge permission.

Vercel: deploys auto-invalidate via a per-deployment cache key, so a manual purge is rarely needed. If you must, redeploy, or use tag invalidation via the Vercel-Cache-Tag header plus a purge API/revalidateTag() call.

Netlify: deploys are atomic and auto-invalidate the prior deploy’s cache. To force it, go to Deploys > Trigger deploy > “Clear cache and deploy site”.

After a purge, the next curl -sI <url> should show cf-cache-status: MISS then age: 0 on the request after that.

Step 2: Force-bust the browser HTML cache

Open DevTools, disable cache, hard reload:

F12 / Cmd+Opt+I  ->  Network tab  ->  check "Disable cache"
Cmd+Shift+R (macOS) / Ctrl+F5 (Windows)

If new content appears now, the browser HTTP cache was the layer. Fix it at the source by lowering the HTML document’s freshness:

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=60

max-age=0 tells the browser to revalidate on every navigation; s-maxage=300 lets a shared cache (CDN) hold it for 5 minutes; stale-while-revalidate=60 serves slightly stale while refreshing.

Step 3: Unregister a rogue service worker

DevTools > Application > Service workers > Unregister. A service worker survives CDN purges because it answers from inside the browser, so this is often the real culprit when “everything else looks fixed.” Then in your site, either remove the registration entirely or ship a self-destructing worker:

// public/sw.js — replace the existing worker with one that unregisters itself
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
  await self.registration.unregister();
  const clients = await self.clients.matchAll();
  clients.forEach((c) => c.navigate(c.url));
});

Deploy this once; every old service worker self-destructs and reloads the page on the visitor’s next visit. Keep the file at the same path the old worker used so it actually replaces it.

Step 4: Verify asset hash references in the HTML

# Which hashes does the live HTML reference?
curl -s https://yoursite.com/articles/x/ | grep -oE '_astro/[^"]+' | sort -u

# Which hashes are in your latest build?
ls dist/_astro/

If they do not match, the cached HTML is older than the asset cache. Re-purge the specific HTML routes (Step 1, “Purge by URL”). Note: a year-long immutable on _astro/* is safe precisely because the filename hash changes on every content change, so the URL itself is the cache key.

Step 5: Add automatic purge to your deploy pipeline (Cloudflare proxy only)

If you run Cloudflare’s proxy in front of your own origin and cache HTML, make purge the last step of every deploy. Vercel and Netlify do this automatically, so skip this step there.

- name: Purge CDN cache
  run: |
    curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
      -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
      -H "Content-Type: application/json" \
      --data '{"purge_everything":true}'

The -f flag makes curl fail the job on a non-2xx response, so a broken token does not silently pass. Cost is one API call per deploy.

Step 6: Set sensible cache headers in your site config

For Astro on Cloudflare Pages, add a _headers file to your static output directory (public/ so it lands in dist/):

/_astro/*
  Cache-Control: public, max-age=31536000, immutable

/*.html
  Cache-Control: public, max-age=0, must-revalidate

/sitemap.xml
  Cache-Control: public, max-age=3600

Fingerprinted assets get a year with immutable (the hash changes on rebuild, so the URL is the cache key). On Cloudflare Pages, HTML is DYNAMIC (not edge-cached) by default, so the HTML header mainly governs the browser; max-age=0, must-revalidate keeps repeat visits fresh while still letting the browser use a 304. If you are on a generic Cloudflare-proxy + own-origin setup with a “Cache Everything” rule, use s-maxage=300, stale-while-revalidate=60 on HTML instead so the edge holds it only briefly. Note that immutable has no effect on Cloudflare’s shared cache — it only changes browser revalidation behavior.

How to confirm it’s fixed

  • curl -sI <url> shows cf-cache-status: MISS on the first request after purge, then HIT with a low age: afterward (or x-vercel-cache: HIT on Vercel).
  • View source shows asset hashes that match dist/_astro/.
  • Hard reload, soft reload, and a fresh incognito window all show the new content.
  • DevTools > Application > Service workers shows either none or your self-destructing worker.
  • Wait 5 minutes and refresh — content stays new (SWR did not fall back to a stale copy).

Long-term prevention

  • Put CDN purge as the last step of every deploy (Cloudflare proxy only; Vercel/Netlify handle it).
  • Use short s-maxage (5-10 min) with stale-while-revalidate for HTML edge caching — fast revalidation, fast first paint.
  • Reserve long max-age + immutable for fingerprinted assets, never for HTML.
  • Do not register a service worker on a content site unless you genuinely need offline support; it survives CDN purges and is a frequent source of “ghost” stale pages.
  • Watch age: and cf-cache-status: / x-vercel-cache: in synthetic monitoring after each deploy to confirm fresh content propagated.

Common pitfalls

  • Purging only the homepage and assuming every article refreshes; each article URL is its own cache entry. Purge by URL for each path, or purge everything.
  • Treating Cache-Control: no-cache as “do not cache.” It actually permits caching but requires revalidation; only no-store truly forbids storing a copy.
  • Leaving Cloudflare’s “Development Mode” on (it disables cache for 3 hours, then re-enables) and concluding a test was clean when caching had simply been bypassed.
  • Setting max-age=31536000 on HTML by mistake — visitors then see stale content for up to a year until they manually clear their browser.
  • Testing in a single browser profile; a service worker or HTTP cache can be sticky to that one profile.
  • Expecting a CDN purge to clear a service worker. It cannot — the worker answers inside the browser (Step 3).

FAQ

Q: I purged Cloudflare but curl still shows the old page. Why? A: Check the cf-cache-status header. If it is DYNAMIC, Cloudflare was never caching that HTML, so the stale copy is in your browser or a service worker, not the edge. If it is HIT with a high age:, your purge likely failed (wrong zone ID, or the token lacks Cache Purge permission) — confirm the API returned "success": true.

Q: Do Vercel and Netlify need a manual purge after deploy? A: Normally no. Both invalidate automatically — Vercel uses a per-deployment cache key, Netlify uses atomic deploys that invalidate the prior deploy’s cache context. Manual purge there is only for tag-based invalidation of long-lived responses.

Q: How often should I purge the whole CDN? A: On every deploy that changes HTML or the sitemap, if you cache HTML at a Cloudflare proxy. Asset-only changes invalidate themselves because the file hash (and therefore the URL) changes.

Q: Should I cache HTML at the CDN at all? A: For a static content site, a short s-maxage (5 minutes) with stale-while-revalidate plus a purge on deploy gives both fast loads and freshness. On Cloudflare Pages HTML is not edge-cached by default, which is also fine for low-traffic sites.

Q: Will purging cost me bandwidth? A: A little — the first request after a purge is a MISS and hits origin. For a content site this is negligible. Purge by URL when you can to avoid re-warming the entire site.

Q: My CDN has no purge API. What now? A: Use the dashboard purge as a mandatory release step, or move to a host that auto-invalidates on deploy (Vercel, Netlify, Cloudflare Pages). Automated cache invalidation is basic production hygiene.

External references

Tags: #content-site #Ops #Troubleshooting