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

Cache layers between a new deploy and the reader Four layers from the reader outward. The service worker cache in the reader's browser, controlled by the site's worker script. The browser HTTP cache, controlled by Cache-Control max-age on HTML. The Cloudflare edge cache, controlled by Cache Rules and purges. The origin, which has the new files immediately after the deploy. Where an old page can be stored Service worker cache in the reader's browser fixed by worker update logic Browser HTTP cache Cache-Control on HTML fixed by max-age=0 Cloudflare edge cache Cache Rules + purge fixed by purge on deploy Origin (S3) new files immediately always current
A purge only clears the third layer; the first two are on the reader's device and need headers or code to change.

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.

Decision tree for diagnosing stale HTML after a deploy The root asks what the response shows. If curl shows cf-cache-status HIT with a large age, the edge is stale and the fix is purging on deploy. If DevTools shows from disk or memory cache, the browser cache is stale and the fix is max-age zero on HTML. If DevTools shows from ServiceWorker, the worker is stale and the fix is network-first navigation with cache cleanup. If curl shows MISS with old content, the origin itself is stale, so check the deploy. Which layer served the stale page? Where did the old HTML come from? curl: HIT, large age Edge purge on deploy DevTools: disk cache Browser max-age=0 on HTML DevTools: ServiceWorker Worker network-first + cleanup curl: MISS, old content Origin check the deploy
One curl and one look at DevTools usually identify the layer in under a minute.

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

MeasureBeforeAfterTool
Time until edge serves new HTMLup to 24 h6 s (purge)Post-deploy check
Time until returning browsers see new HTMLup to 4 hnext navigationDevTools
Readers stuck on service-worker HTML~2,100 (installed workers)0 after activationWorker version telemetry
Edge hit ratio for HTML89%86%Cloudflare analytics
TTFB p75, HTML84 ms88 msRUM
Stale-page reports / month70Support tickets
Maximum time old HTML could be served after a deploy, by layer Paired bars in minutes. At the edge, old HTML could survive up to 1,440 minutes before and 0.1 minutes after. In the browser cache, up to 240 minutes before and 0 after. In the service worker, indefinitely before, shown as 2,880 minutes, and 0 after activation. How long old HTML survived a deploy Before After Edge cache 1440 min 0.1 min Browser cache 240 min 0 min Service worker no limit 0
Worst case per layer before and after the fixes; the service worker had no upper bound before.

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-Control for 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.

Filed underFixesCloudflare