We run Escrozon, an escrow marketplace for digital assets, behind Cloudflare. A while ago we shipped a new landing page. The deploy went fine and the server returned the page, but visitors kept getting a 404 for almost an hour.
Nothing was wrong with the deploy. Cloudflare had cached the 404, and we had caused it ourselves by asking for the page before it existed.
What happened
- Before the deploy, we checked whether the new URL was already live. It returned 404, which was correct at that moment.
- Our Cloudflare cache rule keeps HTML at the edge for an hour (
s-maxage=3600), and it applied to that 404 as well. - The deploy finished and the origin served the page with a 200. Cloudflare kept serving its stored 404 until that copy expired.
It can also look inconsistent. Unless Tiered Cache is on, each Cloudflare data center keeps its own copy, so visitors in one region may see the page while others still get the 404.
How to tell a stale cached 404 from a broken deploy
Start with the response headers:
curl -sI https://example.com/new-page | grep -iE '^(HTTP|cf-cache-status|age|cache-control)'
# HTTP/2 404
# cf-cache-status: HIT
# age: 2140
# cache-control: public, max-age=14400, s-maxage=3600, stale-while-revalidate=86400
-
cf-cache-status: HITmeans Cloudflare answered from its cache without asking your server. -
ageis how many seconds that copy has been stored. -
s-maxage=3600is how long the edge may keep it.
This 404 would stay for another 3600 − 2140 = 1460 seconds, about 24 minutes.
Next, skip the cached copy by adding a query string. Cloudflare treats it as a different URL:
curl -s -o /dev/null -w '%{http_code}\n' "https://example.com/new-page?cb=1"
# 200
A 200 with the query string and a 404 without it means the deploy worked. Only the cached copy is stale.
Fixing it right now
You have three options:
- Purge that URL. In the Cloudflare dashboard, go to Caching → Configuration → Custom Purge and enter the exact URL. "Purge Everything" also works, but it empties the whole cache.
- Purge through the API, which is handy in a deploy script:
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/new-page"]}'
-
Wait until
s-maxageminusageruns out.
Stopping it from happening again
1. Don't request a public URL before it exists. Test on localhost, or directly against your origin server, until the deploy is done. One curl to the public URL is enough to cache the 404.
2. Give 404s a short cache time. Cloudflare Cache Rules let you set an edge TTL per status code. Set 404 (or the whole 4xx range) to no cache, or a few seconds, while pages that work keep their longer TTL.
3. Purge in your deploy script. Call the purge API for the URLs you just shipped, or for everything if the deploy changes shared assets.
4. Know which setting wins. Our app sent s-maxage=300, but the edge served s-maxage=3600 because a Cloudflare rule overrode the origin. Changing the headers in our Next.js config did nothing, because the rule in the dashboard decides.
5. Check Browser Cache TTL. That Cloudflare setting can override the max-age your origin sends to browsers. Ours was 4 hours, so returning visitors saw old pages long after a deploy. Setting it to "Respect Existing Headers" put our origin back in charge.
Measuring cache performance without fooling yourself
Two traps caught us when we tried to measure how fast the cache was:
- A cache-busting query string always forces a MISS. It adds a full trip to your origin, so timings measured with it look worse than what real visitors get.
-
A quick
curl -Iwarms the cache. The next request then looks like a HIT. Measure the status and the timing in the same request:
curl -s -o /dev/null -D /tmp/headers.txt -w '%{http_code} %{time_total}s\n' https://example.com/page
grep -i cf-cache-status /tmp/headers.txt
If your origin is far from many of your visitors, also look at Cloudflare's Tiered Cache. Without it, every Cloudflare location fetches from your origin separately, so the first visitor in each region pays the full trip.
Checklist
-
cf-cache-statusandagetell you whether you're looking at a cached response - A
?cb=1request shows what your origin serves right now - Purge the URLs you ship, as part of the deploy
- 404s get no cache, or a very short one, in your cache rules
- Nobody requests public URLs for pages that aren't deployed yet
- You know whether your origin headers or your Cloudflare rules set the TTL
- Browser Cache TTL is set to "Respect Existing Headers"
Caching 404s isn't a bug in Cloudflare. It does exactly what the rules say. The fix is making sure the rules say what you meant.
This post was written with AI assistance, based on our own incident notes and the commands we ran in production.
Top comments (1)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support