The file on the server is current; your browser shows the old one. A hard refresh fixes it, then a private window shows the old page again. The problem is not your browser cache but the layer in between — and the answer is not to hit purge every time.
Cloudflare caches static assets (CSS, JS, images, fonts) by default and does not cache HTML. If your HTML is being cached, either a rule says cache everything or your server is sending a long Cache-Control for HTML. The right fix is to separate the two layers: version your static assets in the filename or query string so no purge is ever needed, because a new version is a new address. Keep HTML short-lived and purge only the changed addresses after a deploy. To diagnose, read the cf-cache-status header: HIT served from cache, MISS fetched from origin, DYNAMIC not cached at all.
"The old page is showing" has at least three possible causes: the browser cache, the CDN cache, or the server's own cache. Rather than guessing, ask the headers:
curl -sSI https://example.com/ | grep -iE 'cf-cache-status|cache-control|age|last-modified'
What to read:
cf-cache-status: HIT — served from the CDN cache; your origin was never contactedcf-cache-status: MISS — not in cache, fetched from origincf-cache-status: DYNAMIC — this address is not cached at allage: 8412 — how many seconds the response has been sitting in cacheA HIT with a high age on HTML means you have found the problem. A DYNAMIC means the CDN is not involved and you should look at your origin instead.
A static site has two kinds of content whose caching needs are opposites.
Assets (CSS, JS, images, fonts) change rarely, and when they change they change completely. These should stay in cache as long as possible.
HTML changes often and must be visible immediately when it does. It should either not be cached at all, or held for a very short time.
Most deployment problems come from applying one policy to both. "Cache everything" makes the site fast and every update invisible; "cache nothing" makes the CDN pointless.
For assets the right answer is not purging but changing the address. If the address changes whenever the content changes, the old copy sitting in cache stops mattering — nobody asks for it.
<link rel="stylesheet" href="/styles.min.css?v=20260918" />
<script src="/script.min.js?v=20260918" defer></script>
Stronger still is deriving the version string from a hash of the file contents, which makes forgetting to bump it impossible. Whichever method you use, the rule is the same: asset content must never change without the asset address changing.
Once that holds, a one-year cache lifetime on assets is completely safe and no post-deploy purge is needed for them.
Forgetting the version bump is the single weak point of this scheme. Update the CSS without changing the version and visitors get new HTML with old styles, so the page renders broken — and an ordinary purge will not fix it, because the browser is serving the file from its own cache.
There are two reasonable options for HTML. The first is not to cache it at all, leaving the CDN as a TLS-termination and protection layer. The second is a short lifetime — a few minutes, with revalidation.
On the origin side, a short lifetime looks like this:
Cache-Control: public, max-age=300, must-revalidate
That header tells browsers and the CDN the same thing, and at most five minutes after a deploy everyone sees the new content. If your pages already send ETag and Last-Modified, the check made when the lifetime expires is cheap: unchanged content is not downloaded again.
With versioned assets, purging is only needed for HTML — and it should be the final step of your deploy script, not something you remember to do by hand.
# purge only the addresses that changed
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/","https://example.com/services/"]}'
Purging everything (purge_everything) is easy but blunt: all your assets drop out of cache too, the next visitors re-download everything, and your origin takes unnecessary load. On a small site you will not notice; on a busy one you will.
After purging, verify the result — again, do not assume:
curl -sSI https://example.com/ | grep -i cf-cache-status # expect MISS
Development mode. Cloudflare's development mode disables caching temporarily and switches itself off after a few hours. Useful while debugging; a common cause of "the site got slow" when left on.
404 responses are cached too. If a page was deployed incompletely and returned 404, that 404 goes into the cache. Uploading the file afterwards is not enough — the address keeps returning 404 until you purge it specifically.
Order matters. Upload first, then purge. The other way round, the CDN re-caches the old content and nothing appears to have changed.
Origin headers steer the CDN. If you send a long Cache-Control for HTML, browsers will hold the file regardless of your CDN rules — and purging does not clear browser caches.
Seeing stale content after a deploy is not a cache malfunction; it is a missing policy. Version your assets and cache them for a long time; keep HTML short-lived and do a targeted purge as the last step of the deploy script. Once those two are in place, the whole "I forgot to purge" class of mistakes disappears — and understanding what happened always takes just one header: cf-cache-status.
cf-cache-status: HIT from cache, MISS from origin, DYNAMIC not cached?v= or a content hash) — no purge neededmax-age=300, must-revalidate, or do not cache at all{"files":[...]}; purge_everything is a blunt toolStop guessing and read the headers: curl -sSI and look at cf-cache-status, age and cache-control. A HIT with a high age means the response came from the CDN cache. A DYNAMIC means the CDN is not involved and the problem is at the origin or in your browser. That one command separates the three possibilities.
It works, but it is a blunt tool. All your assets drop out of cache as well, so the next visitors re-download everything from CSS to images and your origin takes unnecessary load. If your assets are versioned they never need purging in the first place; target only the HTML addresses that changed.
Because it removes the race condition. When a versioned address changes, the new file is a new address; nobody requests the old copy still sitting in cache, so it never needs clearing. That is what makes a one-year cache lifetime safe, and it reduces deployment to a single variable: remember to bump the version.
404 responses are cached too. Once an incompletely deployed address has returned 404, uploading the file later is not enough — you have to purge that address specifically. Verify new addresses after a deploy, not just the homepage.