Content Site CDN Stale After Rebuild

You shipped a rebuild but production still serves the old article — CDN edge cache, browser cache, or stale HTML referencing old asset hashes.

You merged a content fix, the rebuild deployed cleanly, but visitors keep seeing the old version of the article. Refreshing does nothing. Opening incognito sometimes helps, sometimes does not. The build is correct on disk and the origin returns the new HTML, but the CDN edge between users and your origin is still serving last week’s version. Layers between you and the reader: browser disk cache > browser HTTP cache > CDN edge cache > origin. Each holds its own copy with its own TTL, and a content rebuild often fails to invalidate all of them.

This article walks layer-by-layer cache invalidation and how to set up caching headers so this stops happening.

Common causes

1. CDN edge cache TTL has not expired

Cloudflare / Vercel / Netlify edge caches HTML with TTLs from minutes to hours. If your TTL is 4 hours and the rebuild happened 30 minutes ago, the edge will keep serving old HTML until the TTL elapses naturally.

How to judge: curl -sI https://yoursite.com/articles/x | grep -iE 'age|cache|cf-cache'age: shows how old the cached copy is.

2. Build succeeded but deploy hook did not purge CDN

You build via GitHub Actions, push to S3, but the post-deploy purge step is missing or silently failed. Origin has the new file; edge has the old.

How to judge: read your CI logs — is there an explicit “purge cache” or “invalidate” step, and did it succeed?

3. HTML references stale asset hashes

Astro / Next.js fingerprint assets like main.A1B2C3.js. If the HTML cache still points to old hashes, even a fresh asset fetch resolves the old file.

How to judge: view source on the production page; check whether the JS / CSS hashes match the latest build’s dist/_astro/ filenames.

4. Service worker caching aggressively

If your site registered a service worker during the previous build (PWA, Workbox), it can intercept all requests and serve from its own cache for days.

How to judge: DevTools > Application > Service Workers — if one is registered, it can override everything.

5. Browser HTTP cache holding the old HTML

Cache-Control: max-age=3600 on HTML 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 / Ctrl+F5). If that shows new content, browser HTTP cache was the layer.

6. Stale-while-revalidate masking the issue

stale-while-revalidate returns the old version immediately 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: visit the URL twice in a row. If the second visit shows new content, SWR was at play.

Common causes by layer

Browser disk cache    >  Cmd+Shift+R clears
Browser HTTP cache    >  Cmd+Shift+R + DevTools "Disable cache"
Service Worker        >  DevTools > Application > Unregister
CDN edge cache        >  Purge from CDN dashboard / API
Origin                >  Confirm new file on disk

If “Disable cache + hard reload + incognito” all show old content, the CDN edge is stale. Move up the stack.

Before you start

  • Have the exact URL of one affected page ready.
  • Confirm the latest build deployed successfully (CI green, artifact pushed).
  • Note when the deploy completed — compare to age: from curl.

Information to collect

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

Step-by-step fix

Step 1: Purge the CDN edge cache

Cloudflare: Dashboard > Caching > Configuration > Purge Everything (or Purge by URL for surgical fix).

# Cloudflare API (single URL)
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/"]}'

Vercel: deploys automatically invalidate; if not, redeploy or use vercel deploy --force.

Netlify: Site settings > Build & deploy > Clear cache and retry deploy.

After purge, curl -sI <url> should show age: 0 or close to it.

Step 2: Force-bust the browser HTML cache

Hard reload with DevTools open and “Disable cache” checked:

F12 / Cmd+Opt+I > Network tab > check "Disable cache"
Cmd+Shift+R / Ctrl+F5

If the new content appears now, the browser HTTP cache was the layer. Fix by lowering HTML max-age:

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

max-age=0 tells the browser to revalidate; s-maxage=300 lets the CDN cache for 5 minutes.

Step 3: Unregister rogue service worker

DevTools > Application > Service Workers > Unregister. Then in your site, either remove the SW registration entirely or add a kill-switch:

// public/sw.js (replace existing SW with a self-unregistering one)
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
  const regs = await self.registration.unregister();
  const clients = await self.clients.matchAll();
  clients.forEach(c => c.navigate(c.url));
});

Deploy this once, all old service workers self-destruct on next visit.

Step 4: Verify asset hash references in HTML

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

# What hashes are in your local build?
ls dist/_astro/

If they do not match, the HTML cache is older than the asset cache. Re-purge the HTML routes specifically.

Step 5: Add automatic purge to your deploy pipeline

GitHub Actions example for Cloudflare:

- name: Purge CDN cache
  run: |
    curl -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}'

Run this as the last step of every successful deploy. Cost is one API call per build.

Step 6: Set sensible cache headers in your site config

For Astro on Cloudflare Pages, create _headers:

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

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

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

Hashed assets get a year (immutable, hash changes on rebuild). HTML gets short edge cache with SWR. Sitemap gets an hour.

Verify

  • curl -sI <url> | grep age returns age: 0 to age: 10 immediately after purge.
  • View source shows the new asset hashes matching dist/_astro/.
  • Hard reload, soft reload, and incognito all show the new content.
  • Wait 5 minutes, refresh — content stays new (SWR did not fall back to a stale copy).

Long-term prevention

  • Always include CDN purge as the last step of every deploy pipeline.
  • Use short s-maxage (5-10 min) with stale-while-revalidate for HTML — fast revalidation, fast first paint.
  • Use long max-age only for fingerprinted immutable assets, never for HTML.
  • Never register service workers on a content site unless you genuinely need offline support; the operational cost is high.
  • Monitor age: and cf-cache-status: in your synthetic monitoring after each deploy to confirm fresh content propagated.

Common pitfalls

  • Purging only the homepage and assuming all articles refresh; each article URL has its own cache entry.
  • Using Cache-Control: no-cache thinking it disables caching; it actually allows caching but requires revalidation, which still loads from cache while validating.
  • Forgetting that Cloudflare’s “Development Mode” disables cache for 3 hours only — easy to leave on and confuse a real test.
  • Setting max-age=31536000 on HTML by mistake; users will see old content for a year until they manually clear.
  • Testing only in one browser profile; cache can be sticky to a specific profile.

FAQ

Q: How often should I purge the entire CDN? A: On every deploy that touches HTML or sitemap. For asset-only changes, purge is automatic via hash change.

Q: Will purging cost me bandwidth? A: Slightly — the first request after purge hits origin. For a content site this is negligible.

Q: Should I cache HTML at the CDN at all? A: Yes — s-maxage=300 gives users fast page loads. Short TTL plus purge on deploy gives both speed and freshness.

Q: What if my CDN does not support API purge? A: Switch CDNs or accept manual dashboard purges as a release step. Production hygiene requires automated cache invalidation.

Tags: #content-site #Ops #Troubleshooting