Skip to main content

Cloudflare Pages serves stale HTML after every deploy

I shipped three commits of SEO fixes, every build went green, and the live pages kept serving the old HTML. Not for a few seconds — for hours, and they would have kept doing it for most of a day. The cause was the HTML edge-cache I’d added on purpose a few weeks earlier. A Cloudflare Pages deploy updates the origin; it does not purge the zone’s edge cache. The post that told me to add that Cache Rule even said the edge would serve cached HTML “until the TTL expires or a deploy invalidates the cache.” The second half of that sentence is wrong.

What I shipped

Three commits to main — visible breadcrumbs, trimmed <title> tags, og:image:width/height, a dropped SoftwareApplication JSON-LD node, a category-grouped skills index. All verified in the local dist/ build before pushing. Cloudflare Pages auto-deploys on push; all three builds went green within a couple of minutes.

Then I curled the live canonical URLs to confirm, and got nothing I’d just shipped:

$ curl -s https://agentcookbooks.com/skills/systematic-debugging/ \
  | grep -c SoftwareApplication
1                       # removed two commits ago — should be 0

$ curl -s https://agentcookbooks.com/skills/systematic-debugging/ \
  | grep -oE '"@type":"ListItem"' | wc -l
2                       # the new 4-level breadcrumb wasn't there

Old title on the trimmed posts, no og:image:width, no breadcrumb <nav>. Every one of the three commits was missing — but dist/ was correct and the deploy was green.

The header that explained it

One curl -sI pointed straight at the cause:

$ curl -sI https://agentcookbooks.com/skills/systematic-debugging/ \
  | grep -iE 'cf-cache-status|age'
cf-cache-status: HIT
Age: 32963

HIT, and the cached copy was 32,963 seconds old — about 9.15 hours. The Cache Rule I’d set up makes HTML edge-eligible and honors the _headers directive Cache-Control: public, max-age=3600, s-maxage=86400, which is a 24-hour edge TTL (s-maxage). The deploy had replaced the origin, but the edge was still inside that 24-hour window and kept handing out the pre-deploy HTML. With Age at 32,963 against an s-maxage of 86,400, there were still about 14.8 hours before it would refresh on its own.

Proving it was the edge, not the build

The clean way to separate “deployed wrong” from “deployed fine but stale at the edge” is a cache-buster: append a unique query string so the URL isn’t in the cache, forcing a MISS and an origin fetch.

$ curl -s "https://agentcookbooks.com/skills/systematic-debugging/?cb=$(date +%s)" \
  | grep -c SoftwareApplication
0                       # origin is correct

$ curl -s ".../skills/systematic-debugging/?cb=$(date +%s)" \
  | grep -oE '"@type":"ListItem"' | wc -l
4                       # the new breadcrumb tier is there

$ curl -s ".../blog/cloudflare-pages-html-cache-week-one-449-visitors/?cb=$(date +%s)" \
  | grep -oE '<title>[^<]*</title>'
<title>Week one on Cloudflare Pages: 449 visitors, 5.99% cached</title>

Six change-signals, all stale at the bare canonical URL, all fresh at the ?cb= URL. That’s conclusive: the build was right, the origin was right, and the only thing wrong was that the edge was serving a snapshot from nine hours ago.

The fix, and why the deploy didn’t do it

Cloudflare dashboard → the zone → Caching → Configuration → Purge Cache → Purge Everything (or purge the specific changed URLs). The next request is a MISS, the edge re-fetches the new deployment, and the canonical URLs go fresh. The alternative is to wait out the s-maxage window.

The trap is that a Pages deployment and the zone’s Cache Rule are two systems sharing one dashboard. The deployment is immutable-per-version and updates what the origin serves. The Cache Rule caches by URL at the edge and has no idea a deployment happened — nothing in the Pages deploy pipeline issues a purge against it. So the more correct your caching setup is, the longer your deploys stay invisible.

This matters more for SEO than for humans. A returning visitor might shrug at a few stale hours. Googlebot re-crawls on its own schedule, and if it crawls inside the stale window it indexes the old page — so the entire point of an SEO deploy (the new titles, breadcrumbs, schema) is invisible to the crawler for up to a day, exactly when you most want it seen.

What I’d change

Two moves, both cheap:

  1. Add a purge to the deploy ritual. Once an HTML Cache Rule exists, “push to main” is no longer the last step — “purge” is. On this site that’s a manual dashboard click; a project with a Cloudflare API token can fire a purge from CI right after the Pages build reports success.
  2. Keep the cache-buster in the back pocket. ?cb=$(date +%s) is the fastest “is it deployed or just stale?” test there is — no purge, no waiting, no guessing. If the ?cb= URL is fresh and the bare URL is stale, you don’t have a build problem, you have a cache problem, and the fix is a purge, not another commit.