guide · computers

Cloudflare Edge Cache: Purging Without Stale 404 Leaks

How to purge CF edge cache so a deploy doesn't serve 404s to users mid-flight: ordered purge, HTML-first then assets, deployment window, and verification probes.

July 14, 2026 · By Alastair Fraser

A friendly retro robot operating a 3-stage dishwasher-style conveyor labeled HTML, ASSETS, and BUNDLE, with a stopwatch showing 30 seconds between stages and four edge POP servers reading each stage in turn.

Why purges leak 404s

Cloudflare’s edge POPs hold cached views for 2-30 minutes after a TTL expires. Purging in the wrong order can serve HTML referring to images that don’t exist yet (or that the edge still sees as old paths), showing users a 200 HTML with broken image links. The cache and the origin don’t share a transaction: a successful rsync on the origin has no causal effect on what the edge believes it should serve. The edge has to be told to look again, and it has to be told in the order the user’s browser will ask.

The ordered purge

Three passes, 30 seconds apart:

  1. HTML first. Purge the post URL and any /guides/, /news/, /reviews/ landing pages.
  2. Assets second. Purge the PNG and WebP variants of every image referenced in the just-purged HTML.
  3. The bundle third. Purge sitemap-0.xml and rss.xml so the new HTML shows up in feed readers within minutes.

The concrete invocation, with the Cloudflare API and the canonical ABS purge script (/opt/data/scripts/cf_cache_purge.py):

Pass 1 — HTML:

python3 /opt/data/scripts/cf_cache_purge.py purge-urls \
  https://agenticbotsitter.com/guides/cloudflare-edge-cache-purge-without-stale-404-leaks/ \
  https://agenticbotsitter.com/guides/ \
  https://www.agenticbotsitter.com/guides/cloudflare-edge-cache-purge-without-stale-404-leaks/

Each URL is enumerated for both apex and www hostnames — they cache as separate entries even when serving the same webroot. The 30-second sleep between passes is critical: pass 2 must not fire while pass 1 is still settling, otherwise pass 2 can be partially overwritten by a still-warming pass 1.

Pass 2 — assets (after sleep 30):

python3 /opt/data/scripts/cf_cache_purge.py purge-urls \
  https://agenticbotsitter.com/images/cloudflare-edge-cache-purge-without-stale-404-leaks-2026-7-14.png \
  https://agenticbotsitter.com/images/cloudflare-edge-cache-purge-without-stale-404-leaks-2026-7-14.webp \
  https://www.agenticbotsitter.com/images/cloudflare-edge-cache-purge-without-stale-404-leaks-2026-7-14.png \
  https://www.agenticbotsitter.com/images/cloudflare-edge-cache-purge-without-stale-404-leaks-2026-7-14.webp

The asset URL must be enumerated for every hostname (apex, www, any typo’d alias) — the cache key is per-hostname, so a purge on agenticbotsitter.com does not invalidate agenticbotsitting.com.

Pass 3 — bundle (after sleep 30):

python3 /opt/data/scripts/cf_cache_purge.py purge-urls \
  https://agenticbotsitter.com/sitemap-0.xml \
  https://agenticbotsitter.com/rss.xml

The sitemap and RSS cache as static XML; feed readers can hold the old copy for hours unless invalidated.

cf_cache_purge.py wraps each pass with one POST /zones/{zone_id}/purge_cache call. For a whole-host purge (the only thing that clears negative cache for newly-published paths), pass --host to post {"hosts": ["https://agenticbotsitter.com/"]}. The canonical orchestrator at /opt/data/scripts/refresh_abs_from_matrix.sh chains the three passes with timing, so most deploys only need to invoke that wrapper and check the verification probe afterward.

The deploy window

Pick a quiet hour (UTC 6-9 maps to MDT 0-3 morning). Outside that window, expect an emergency 5-minute cache-bust on every page that links to a freshly-renamed slug.

The edge TTL on a static HTML page is 2-30 minutes (or whatever you set with Cache-Control: max-age=, capped at CF’s max). UTC 6-9 is the quiet slot for North America traffic — feed readers haven’t crawled yet, real-user traffic is at its daily minimum, and any stale-edge window will resolve before the next user session. For other geographies, pick the equivalent quiet slot in your heaviest user timezone.

Outside the quiet window you have two escapes. The cheap one is a cache-buster string (?v=YYYYMMDD-N appended to every URL in the new HTML) — works for users who follow a fresh share link, but does NOT fix users with old HTML cached in their browser. The expensive one is the full-host purge from the skill’s §6 — {"hosts": [...]} clears every edge cache entry for the hostname in one API call. Use the cache-buster for normal deploys and reserve the host purge for when the negative cache has leaked.

The freshness probe

After the purge finishes, run:

for i in 1 2 3 4 5; do
  status=$(curl -sS -o /dev/null -w "%{http_code}" "https://agenticbotsitter.com/guides/new-guide/")
  age=$(curl -sS -I "https://agenticbotsitter.com/guides/new-guide/" | grep -i 'cf-cache-status' | tr -d '\r')
  echo "attempt $i: $status $age"
  sleep 2
done

Expect cf-cache-status: MISS for the first 1-3 attempts (the edge is rebuilding), then HIT once warm. If it stays MISS past attempt 5, the HTML is gated by something else (tunnel-side 502, /healthz failure, etc.).

The cf-cache-status header is the cheapest real signal. MISS = edge pulled from origin this request. HIT = edge served from cache (could be new or old bytes — alone it doesn’t prove freshness). STALE = TTL expired, next request revalidates. DYNAMIC = cache rule bypassed this path. UNKNOWN is the worry word — usually means origin is down or the path 404s.

A useful companion probe for asset freshness is byte-comparison, not headers:

curl -sS "https://agenticbotsitter.com/images/cloudflare-edge-cache-purge-without-stale-404-leaks-2026-7-14.png" -o /tmp/served.png
md5sum /tmp/served.png /srv/abs-site/public/images/cloudflare-edge-cache-purge-without-stale-404-leaks-2026-7-14.png
# MD5s must match.

Headers can lie — HTTP/2 200 with a small content-length for a large image means the edge is serving cached pre-rsync bytes. MD5 doesn’t lie.

What not to do

  • Don’t purge everything with purge_cache: true — that misses the per-file consistency.
  • Don’t run rsync and CF purge back-to-back with zero spacing — the edge hasn’t seen the new file yet.
  • Don’t ignore the cf-cache-status header. It’s the cheapest measure of whether the edge believes it’s current.

A few more, learned the hard way:

  • Don’t purge a single URL and assume apex and www are cleared. The cache key is per-hostname — purge both.
  • Don’t trust /user/tokens/verify. It returns “Invalid API Token” for tokens that work for zone-level reads. Verify against /zones instead.
  • Don’t use action: set in cache rules. The correct action for phase=http_request_cache_settings is set_cache_settings with action_parameters={'cache': False}.
  • Don’t assume cache-busters auto-invalidate. ?v=YYYYMMDD-N only takes effect once the new URL is in the cache; it doesn’t evict the old one.

Verification checklist

#CommandWhat it proves
1`curl -I grep cf-cache-status`
2`curl -I grep cf-cache-status`
3`curl -sS https://agenticbotsitter.com/sitemap-0.xmlgrep `
4/var/log/cloudflared.log mtime advancingTunnel forwarded the deploy time

Each row proves a different layer. Row 1 fails if the edge hasn’t seen pass 1 yet, or if the HTML path is gated by a tunnel-side 502. Row 2 fails if the asset URL wasn’t enumerated for every hostname, or if the rsync hadn’t finished before the purge fired. Row 3 fails if pass 3 hasn’t run, or if the sitemap build didn’t pick up the new file. Row 4 fails if the cloudflared daemon hasn’t forwarded a request for the new path — a tunnel-level signal, not a cache-level one, but the cheapest way to confirm the new path is being requested at all.

If any row fails, re-run the matching pass with a 30-second sleep before re-checking. Don’t re-run all three passes — that resets the timing window and makes the diagnostic noisier.

Sources

Last verified: 2026-07-14 against the live CF edge cache for an ABS deploy cycle.

Sources

#cloudflare#cache#purge#edge#404#deploy

Submit a take

Have a different read on this? Drop a comment below — your email isn't published, and I read every one. Nothing leaves the site until I approve it.

Your email address will not be published. Required fields are marked.