Fixing Stale HTML After Deploys on Cloudflare
The deploy finished, the preview looked right, and an hour later a reader reports that the page still shows yesterday's pricing. Then a second reader cannot reproduce it. Stale HTML after a deploy is one of the most common incidents on static sites behind Cloudflare, and it is confusing because there are several caches between the new files and the reader, each with its own rules, and purging one leaves the others untouched.
This guide traces stale pages on a marketing and docs site served from an S3 origin through Cloudflare — the setup before a later move to Workers Static Assets — finds three separate causes, fixes each, and adds a post-deploy check that proves readers get the new HTML. It belongs to CDN Caching Rules for SSGs; the related deploy steps are in Purging the CDN Cache After a Static Deploy.
The Caches Between Deploy and Reader
Step 1: Find Which Layer Served the Old Page
From a machine that had never visited the site, curl shows what the edge returns:
curl -sI https://example.com/pricing/ | grep -iE 'cf-cache-status|age|cache-control|last-modified'
# cf-cache-status: HIT
# age: 41210
# cache-control: public, max-age=14400
HIT with an age of 11 hours meant the edge was serving a copy older than the last two deploys. That was the first cause. In a browser that had been reporting stale pages, DevTools' Network panel showed the HTML "from ServiceWorker" even after the edge was purged — the second cause. And max-age=14400 (four hours) on the HTML was the third: browsers would keep the old page for four hours without asking.
Cause 1: A Cache Everything Rule Without a Purge
A Page Rule from two years earlier set "Cache Level: Cache Everything" and "Edge Cache TTL: 1 day" on example.com/*, added to improve TTFB. It worked — and since the deploy pipeline did not purge Cloudflare, every page stayed at the edge for up to a day after each deploy.
The fix kept HTML at the edge, which was valuable, and added the missing purge. The deploy job now computes changed HTML paths from the build manifest and purges them through the API:
jq -r '.changed[] | "https://example.com" + .' manifest-diff.json | split -l 30 - batch-
for f in batch-*; do
jq -Rn '[inputs] | {files: .}' "$f" | curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" -H 'Content-Type: application/json' --data @-
done
The Page Rule was migrated to a Cache Rule with an edge TTL of a day and stale-while-revalidate behaviour on the origin's headers, so a missed purge would be bounded.
Cause 2: Browser max-age on HTML
The origin uploaded HTML with Cache-Control: public, max-age=14400, set by an S3 sync command that applied one header to every file. Browsers that had visited within four hours never asked for the new page, and no purge can reach a browser cache. HTML now gets public, max-age=0, must-revalidate — the browser revalidates every navigation — while the edge keeps its own TTL from the Cache Rule. The sync was split into two commands, as in Cache-Control Headers on S3 and CloudFront:
aws s3 sync dist/ s3://$BUCKET/ --exclude '*.html' --cache-control 'public, max-age=31536000, immutable'
aws s3 sync dist/ s3://$BUCKET/ --exclude '*' --include '*.html' --cache-control 'public, max-age=0, must-revalidate'
Cause 3: A Service Worker Serving Cached HTML
The site had a service worker from an offline-reading experiment, using a cache-first strategy for every navigation. Readers who had it installed saw cached HTML indefinitely; a new worker version was only picked up after all tabs closed, and even then it kept the old cache. That explained why the reports came from regular readers and could not be reproduced by anyone else.
The worker was changed to network-first for navigations with a short timeout, falling back to cache only offline, and given a version that deletes old caches on activation:
const VERSION = 'v14';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(
caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => self.clients.claim())));
self.addEventListener('fetch', (e) => {
if (e.request.mode !== 'navigate') return;
e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
});
skipWaiting and clients.claim make the new worker take over without waiting for every tab to close. Sites that do not need offline reading should remove the worker entirely, with a self-unregistering worker shipped at the same URL.
Step 2: Prove Each Deploy Is Live
A post-deploy step fetches a sample of changed pages through Cloudflare and checks that each contains the new build's ID, which the build writes into a <meta name="build-id"> tag:
for p in $(jq -r '.changed[:20][]' manifest-diff.json); do
got=$(curl -s "https://example.com$p" | grep -o 'name="build-id" content="[^"]*"' | cut -d'"' -f4)
[ "$got" = "$BUILD_ID" ] || { echo "stale: $p ($got)"; exit 1; }
done
A failure means a purge did not happen; the job retries the purge once and then alerts. This check would have caught Cause 1 on its first day. Post-Deploy Verification Checklists covers the broader set of checks.
Stale Assets Are a Different Problem
Some reports that looked like stale HTML were the opposite: new HTML referencing a CSS file the reader's browser could not load, because the deploy had deleted the previous build's hashed assets while an old HTML page was still open in a tab. Keeping the previous few builds' assets in the bucket for a week fixed those, as in Keeping Old Assets Alive After Deploy.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| Time until edge serves new HTML | up to 24 h | 6 s (purge) | Post-deploy check |
| Time until returning browsers see new HTML | up to 4 h | next navigation | DevTools |
| Readers stuck on service-worker HTML | ~2,100 (installed workers) | 0 after activation | Worker version telemetry |
| Edge hit ratio for HTML | 89% | 86% | Cloudflare analytics |
| TTFB p75, HTML | 84 ms | 88 ms | RUM |
| Stale-page reports / month | 7 | 0 | Support tickets |
The HTML hit ratio and TTFB barely moved, so keeping HTML at the edge with purges kept nearly all of the performance benefit.
Pitfalls & Rollback
- Purging the edge and assuming readers are fixed. Browser and service worker caches need headers and code.
- One
Cache-Controlfor every file. HTML and hashed assets need opposite policies. - Cache-first service workers for navigations. Use network-first unless offline reading is the product.
- No post-deploy check. Prove the new build is live; do not assume it.
- Rollback: each fix is independent; removing the Cache Rule disables edge HTML caching entirely, which is the safest fallback if purges are failing.
Conclusion
Stale HTML after a deploy on Cloudflare usually means more than one cache is holding the old page. On this site an edge rule with no purge, a four-hour browser max-age on HTML, and a cache-first service worker each kept old pages alive. Purging changed paths on deploy, revalidating HTML in browsers, switching the worker to network-first and checking the build ID after each deploy brought the time to see a new page from up to a day to the next navigation.
FAQ
Why do some readers see the old version of a page after I deploy?
Something between the deploy and the reader still holds the old HTML. On Cloudflare the usual layers are a Cache Rule or Page Rule caching HTML at the edge without a purge, a browser cache TTL or max-age on HTML, and a service worker serving cached pages. Each layer needs its own fix; purging the edge does nothing for a browser or service worker cache.
Does Cloudflare cache HTML by default?
No. Cloudflare's default cache behaviour does not cache HTML responses; it caches static file extensions. HTML is cached only when a Cache Rule or Page Rule says to cache everything or when the origin sends Cache-Control allowing it and the rule respects origin headers. Sites on Cloudflare Pages or Workers Static Assets get their own caching behaviour that is invalidated on deploy.
How do I check which layer served a stale page?
Look at the response headers. cf-cache-status HIT with a large age means the edge served it; a response marked from disk cache or from memory cache in DevTools means the browser; a response from ServiceWorker in DevTools means a service worker. Fetching the URL with curl bypasses the browser and service worker and shows what the edge returns.
Is it safe to cache HTML at the edge for a static site?
Yes, as long as every deploy purges the changed pages, or the edge TTL is short with stale-while-revalidate. Caching HTML at the edge cuts TTFB significantly for readers far from the origin. The mistake is caching it without a purge path.
Related
- Parent: CDN Caching Rules for SSGs — caching policy for static sites.
- Purging the CDN Cache After a Static Deploy — purge strategies in depth.
- Stale-While-Revalidate for Static HTML — bounding staleness when a purge fails.
- Cache-Control Headers on Cloudflare Pages — the same policy on Pages.
- Keeping Old Assets Alive After Deploy — the asset side of deploys.
Filed underFixesCloudflare