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>includingage:,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 agereturnsage: 0toage: 10immediately 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) withstale-while-revalidatefor HTML — fast revalidation, fast first paint. - Use long
max-ageonly 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:andcf-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-cachethinking 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=31536000on 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.