CrawlCheck

Findings · 2026-08-13 · By · 0 views

Your cache purge returned 200 and evicted nothing

Four successful-looking cache purges cleared zero objects. Here is the two-request test that catches it, and the script that runs it.

A cache purge that returns HTTP 200 has not necessarily evicted anything. The API accepted our request, answered success, and kept serving the old bytes, because the purge targeted a URL form that did not match the cached key. The only proof a purge worked is a fetch afterwards that shows the new content and a fresh age header — the status code of the purge call proves the call was accepted, nothing more.

The two-request test
Bare URLwhat a visitor and a crawler receive
?bust=<now>what the origin generates right now
>2% aparta cache is serving a document your server stopped producing

A page had been edited. The change was live in the database, live in the generated HTML, and invisible to every visitor and every crawler for two hours and twenty minutes.

We purged the cache four times. Every purge returned HTTP 200. Not one of them evicted a single object.

Why a 200 means nothing here #

A PURGE request aimed at a public URL has to be intercepted by the cache tier to do anything. On a lot of managed hosting it is not: the request sails straight past the cache, reaches the application, and the application answers it like any other request — with a page, and a 200.

That 200 says something answered. It does not say a cache dropped anything. From the outside the two are indistinguishable, which is why four failed purges in a row looked like four successful ones.

The two-request test #

Stop reading status codes and ask a differential question instead. Fetch the page twice:

curl -s https://example.com/            | wc -c
curl -s "https://example.com/?bust=$(date +%s)" | wc -c

The first is what a visitor and a crawler receive. The second carries a query string almost no cache has an entry for, so it comes from the origin as it is right now.

If those two numbers disagree by more than a couple of percent, a cache is serving a document your server no longer produces. No status code anywhere in your stack will tell you that.

What the headers confirm #

On the stale response we saw x-cache: HIT and age: 8386 — the copy being served was 8,386 seconds old. The cache-busted response showed age: 0 and a different byte count entirely.

Worth sitting with that number if you run a site behind managed hosting: every change you make can stay invisible for over two hours, including changes a crawler would have to see for you to appear in an answer. It is not a rare condition. STALE_CACHE_SERVED is the most common finding in this scanner’s dataset, on 17% of scans as of the moment you are reading this.

Read the two cache headers together: the pair names the layer #

Most sites now sit behind two caches, not one — a server-side cache at the host and a CDN at the edge — and each writes its own header. Read singly, either one can look innocent. Read together, they tell you which layer is holding the stale copy.

CDN headerHost cache headerWhat you are looking at
cf-cache-status: HITanythingthe edge’s copy; the host was never asked
cf-cache-status: MISSx-cache: HITthe edge asked the host and the host served its stale copy
MISSMISSthe origin, finally — the only reading that describes the site

The second row is the one that wastes afternoons. We have purged a CDN, watched the edge report a miss, and been handed the same stale bytes anyway — because the host cache behind it still held them and simply re-supplied them. On one occasion a full edge purge re-cached the old page within thirty-five seconds of clearing it, because nothing had been cleared upstream.

Purge order is not optional #

Which gives the rule: purge the cache nearest the origin first, then the edge. Host cache, then CDN. Done the other way round, the edge refills from the stale host copy the moment anything requests the page, and the purge you just ran has been undone before you finished reading its 200.

The same logic explains why a new cache rule does nothing to what is already cached. Rules govern what gets stored next. Purges govern what is stored now. Neither one does the other’s job, and a rule added to fix a stale object leaves that object exactly where it was.

Two ways the test itself lies #

The differential test is only as honest as its two readings, and we have been caught by both sides of it.

The cache-buster can change the page. Some optimisation plugins skip lazy-loading and script deferral on any request carrying a query string, so the “fresh” fetch is rendered differently from the bare one — fewer images, no lazy attributes — and the byte counts disagree for a reason that has nothing to do with staleness. The opposite also happens: a page cache that keys on the path and ignores query strings will serve the same stale copy to both requests, and the test reports fresh when nothing was checked. On one of our own properties a Cache-Control: no-cache request header did not bypass the application’s page cache at all, and only a query string did. Know which of your layers honours which signal before trusting either reading.

The cache-buster can be a reserved word. We once measured a site through ?m=1 and got a clean, cheerful reading — of a 404 page. On WordPress m is the date-archive query variable, so the “fresh” fetch rendered the not-found template, byte-for-byte consistent and about nothing. The parameter name has to be one the application does not own: bust, cb, a nonce — never m, p, s, page_id or anything a framework routes on.

The script #

Take the script — it is the one we run, published in full. We wrapped the test up. It fetches both, compares them, reads the cache headers, and — importantly — refuses to judge at all when either reading is a challenge page or an error rather than reporting a number it cannot stand behind.

That guard exists because the first version of our own script got it wrong: one side came back as a 163-byte interstitial and it confidently reported the cache as 36,504% stale. It validated one side of the comparison and trusted the other, which is the same mistake as trusting the 200 — and the same one that lets a challenge page pass for a robots.txt.

What the purge returned, and what the edge kept serving #

SignalReading
Purges issued4
HTTP status on every one200
Objects evicted0
x-cache on the stale responseHIT
age on the stale response8,386 seconds

A 200 says something answered. On a purge aimed at a public URL, that something is often the application, not the cache tier.

What actually clears it #

Not a PURGE to the public URL. In our case it took one click in the hosting stack’s own cache control — and the same click also cleared an object cache that was separately holding stale application state.

On another property the full sequence turned out to be four layers deep: delete the page-cache files on disk, send a PURGE to the host cache from inside the application with the right Host header, then purge the edge — with no verification fetch in between, because every fetch you make to check re-primes the next layer with the stale copy from the one below it. Checking too early is how a correct purge sequence gets undone by the person running it.

If a purge is not working, the question is never did it return 200. It is which layer is answering, and does the thing I clicked own that layer. That is the same question as whether a write that returned success stored what you sent: the status describes the request, and only a second, independent read describes the effect.

Every figure above came out of this scanner.

Point it at your own domain and see the same measurements, free.

Scan a domain — free

The main product

Found this on your own site? We fix it for $749.

Scan free to see where you stand. The fix is one site, every finding implemented and re-measured, with a sealed before and after.

Questions this post answers

Does a 200 from a cache purge mean the cache cleared?

No. It means the API accepted the call. Four successful-looking purges in this case evicted zero objects.

How do I test whether a purge actually worked?

Two requests: fetch the object, purge, fetch again, and compare the bytes and the cache headers. Identical bytes after a purge means a layer is still serving the old copy.

Which cache layer is usually the culprit?

Whichever one is not the layer you purged. Read both the origin and the edge cache headers before deciding which control to use.

How do I prove a purge worked?

Fetch the URL from outside your network afterwards and read the age header. A fresh age and the new content is proof; a 200 from the purge API only proves the request was accepted.

Why would a purge miss?

Usually a key mismatch: the cached object is keyed on a URL form, host or header set that your purge request did not name. Purging the apex when the object was cached under www is the common version.

Related findings

How anything measured in this article was measured15client identitiesone second, one address5machine filesapex and www114named agentsresolved from robots.txt24sections scoredreach, read, quoteHow anything measured here was measured15 client identities5 machine files114 named agents24 sections scoredone second, one addressapex and wwwresolved from robots.txtreach, read, quote
No account, nothing installed, and the same sequence on every domain — which is what makes one scan comparable to another. Run it on your own site.

Comments

Comments are read before they appear. Nothing is published automatically, and no account is needed.

Writing about this? Facts, live figures and marks — every number on that page is dated and traceable to a scan.

All findings · The dataset · How the dataset works