A 90-minute debugging session, with curl outputs.
TL;DR
We run a 6,800-page games portal behind Cloudflare on the Free plan. Last week's audit flagged that cf-cache-status was returning DYNAMIC for every HTML request, despite an origin Cache-Control: s-maxage=31536000. We spent 90 minutes building Cache Rules with every "correct" config — Eligible for cache, Edge TTL, browser TTL, expression filters covering 8 path patterns — and Cloudflare still refused to cache HTML. The fix wasn't another tweak to Cache Rules; it was switching to the older Page Rules with Cache Everything + Edge Cache TTL. Within 10 seconds of saving, our second-request TTFB dropped from 660ms to 210ms (a 68% reduction on keep-alive connections, much larger globally) and age: headers started incrementing. This post is the actual headers we saw, the configs we tried, and why the docs don't tell you this directly.
The setup
DooDoo.Love is a multilingual HTML5 games portal. ~6,800 game pages, plus categories, tags, blog, news. Origin is Vercel; Cloudflare sits in front for DNS + a few security headers + (we hoped) HTML caching. We're on the Free plan because it's enough for our traffic and we'd rather spend the $20/month elsewhere.
The full SEO audit pegged our indexing rate at 18.6% — Google had crawled and indexed only 1,250 of our 6,725 sitemap URLs. One contributing factor (among several): Googlebot was paying full origin TTFB for every page in every region. From Tokyo, that meant 1,800ms just to start receiving HTML. With a flat crawl budget, that math eats most of the budget on protocol overhead instead of actual page content.
Edge caching HTML at Cloudflare's POPs would mean Googlebot in Tokyo hits the Tokyo POP (round-trip ~30ms) instead of LAX (round-trip ~150ms). On 6,800 URLs, that's a meaningful budget unlock. So: configure CF, get cf-cache-status: HIT, ship it.
It did not go that way.
What we tried first: Cache Rules
Cloudflare's modern caching configuration UI is Cache Rules (under Caching → Cache Rules). Page Rules are the legacy interface, and the docs nudge you toward Cache Rules. So we built one:
- Rule name: Cache HTML edge
- Match expression:
(http.host eq "doodoo.love" and http.request.method eq "GET" and (
starts_with(http.request.uri.path, "/games/")
or starts_with(http.request.uri.path, "/blog/")
or starts_with(http.request.uri.path, "/news/")
or starts_with(http.request.uri.path, "/categories/")
or starts_with(http.request.uri.path, "/tags/")
or http.request.uri.path eq "/"
[...]
))
- Cache eligibility: Eligible for cache
- Edge TTL: Use cache-control header if present, otherwise bypass
- Browser TTL: Override origin TTL → 4 hours
Saved. Deployed. State: 活动 (Active). Order: 1.
We then ran:
$ curl -sI https://doodoo.love/games/sudoku | grep -i "cf-cache-status"
cf-cache-status: DYNAMIC
Five attempts in a row, each with a 2-second sleep between. Every single one: DYNAMIC. No MISS → HIT progression. No age: header.
The origin response was healthy — cf-ray confirmed Cloudflare was on the path, x-nextjs-cache: HIT confirmed Vercel was caching its end, and cache-control: s-maxage=31536000, stale-while-revalidate was being sent. There was no Set-Cookie. No Vary: *. No Pragma: no-cache. The response was, by every spec we knew, cacheable.
Diagnostic detour: ruling out the obvious
We worked through the standard checklist:
- Was the rule actually deployed (not draft)? Yes, status was 活动 (Active).
- Was there a higher-priority rule overriding? No, this was the only Cache Rule.
- Was the expression matching? We tested a path explicitly not in the expression (
/api/test-not-in-rule) and gotDYNAMICtoo — but that's expected because it's not in the rule. The match question stayed inconclusive from headers alone. - Was Cloudflare seeing the request? Yes, every response had
cf-ray:andserver: cloudflare. - Was a static asset cacheable?
curl -sI .../logo.pngreturnedcf-cache-status: MISS(cacheable, just not yet warmed). So Cloudflare's cache pipeline was not broken globally — only HTML was failing. - Did switching Edge TTL to "Ignore cache-control header, use this TTL" fix it? No. Even forcing Cloudflare to use a manual 1-hour TTL, ignoring the origin entirely, the response stayed
DYNAMIC.
That last data point was the one that broke our model. If "Ignore cache-control" + a hard TTL doesn't cache an HTML response, the rule isn't being honored at all. Something below the rule layer was vetoing the cache.
The actual answer
Cloudflare Free Plan + Cache Rules + text/html is an empirically unreliable combination. The Cache Rules feature is technically available on Free, but caching of HTML/dynamic content has documented quirks that don't apply to static assets.
We confirmed plan tier in the dashboard (top right: "Free"), and we confirmed the rule was correctly configured. Cache Rules just doesn't reliably cache text/html for Free-tier accounts in 2026, regardless of how perfectly you configure it.
The interesting thing is the docs don't say this directly. They say Cache Rules are available on Free. They don't say "but for HTML on Free, use Page Rules instead." We figured this out by switching.
The fix: Page Rules with Cache Everything
Page Rules predate Cache Rules and have a different code path inside Cloudflare. On the Free plan you get 3 rules. Here's what we set:
Rule 1: *doodoo.love/games/*
- Cache Level: Cache Everything
- Edge Cache TTL: 2 hours
Rule 2: *doodoo.love/categories/*
- Cache Level: Cache Everything
- Edge Cache TTL: 2 hours
Rule 3: *doodoo.love/tags/*
- Cache Level: Cache Everything
- Edge Cache TTL: 2 hours
Cache Level: Cache Everything is the magic incantation. Without it, Cloudflare's default Cache Level (Standard) is "cache only static file extensions" — which excludes HTML even though text/html is the largest miss in the URL space. Cache Rules' "Eligible for cache" eligibility flag should be the equivalent override, but on Free it isn't (or isn't fully). Page Rules' Cache Everything is.
We saved, then purged:
Caching → Configuration → Purge Cache → Purge Everything
Then verified:
$ curl -sI https://doodoo.love/games/sudoku | grep -i "cf-cache"
cf-cache-status: MISS
$ sleep 3
$ curl -sI https://doodoo.love/games/sudoku | grep -iE "cf-cache|^age"
cf-cache-status: HIT
age: 3
age: 3 — the response had been at the edge for 3 seconds. Two minutes later, age: 124. The HIT was real.
We hit /categories/puzzle and /tags/puzzle-games and saw the same MISS → HIT pattern. The three Page Rules covered our highest-traffic surface: ~6,820 game pages plus 14 category pages plus 33 tag pages = ~6,867 URLs cached at edge.
What didn't speed up: TTFB on the same connection
The first thing we noticed after the switch is that TTFB didn't change on a fresh curl. From our LA-area test machine to the Cloudflare LAX POP, TTFB stayed around 660ms whether the response was DYNAMIC or HIT. We almost reverted, thinking the Page Rule wasn't actually doing anything despite the headers.
Then we ran three requests in a single keep-alive curl session:
Req 1: TTFB 0.648s, total 0.708s ← cold connection, full TLS handshake
Req 2: TTFB 0.210s, total 0.530s ← reused connection, hit edge cache
Req 3: TTFB 0.211s, total 0.275s ← same
Request 1 is dominated by the TLS handshake (~440ms in our time_appconnect). Cloudflare HIT or origin pass-through, that handshake cost is the same. Requests 2 and 3 reuse the connection and now expose the actual cache delta: 660ms → 210ms = −68%.
Most real users don't run curl cold once. They open a tab, the browser establishes a connection, and then loads the HTML + dozens of subresources over that same connection. The "cold first hit" is a small fraction of total user-perceived latency. The cache win shows up on every subsequent request.
For Googlebot and other crawlers, the win shows up differently again. From the Cloudflare Tokyo POP, our origin in LAX is ~120ms RTT away. From an Asian user/crawler, hitting the Tokyo POP is ~30ms RTT. Pre-cache: 30ms RTT (user→Tokyo) + 120ms RTT (Tokyo→LAX) + origin work + return path = ~180-220ms. Post-cache: 30ms RTT (user→Tokyo) + 5-15ms (cached HTML out) + return path = ~50-80ms. The win on a global audience is much larger than the LA-to-LA test shows.
What we'd do differently
Don't start with Cache Rules on a Free plan if your goal is HTML caching. Cache Rules are the future, the docs treat them as canonical, and on Pro+ they work perfectly for HTML. On Free in mid-2026, Page Rules with Cache Everything are still the proven path. Start there. Migrate to Cache Rules when you upgrade.
Always verify with age: not just cf-cache-status: HIT. The cf-cache-status field is generated as Cloudflare formats the response; it can theoretically be HIT while the origin was actually consulted. The age: header is harder to fake — it's the seconds since the cached response was stored. If age: increments across requests, you have real edge caching.
Test with TLS handshake folded out. Cold-curl TTFB will mislead you when the cache delta is similar in size to the TLS handshake. Use keep-alive (curl with multiple URLs in one invocation) or run from a region far from your origin to expose the real cache benefit.
Free plan trade-offs are real but not large. We get 3 Page Rules; we used them on /games/*, /categories/*, and /tags/* (the three highest-traffic surfaces). The other ~1% of pages (/blog, /news, /about, root) stay uncached. For our use case, that's fine — those routes aren't crawled or hit at the same volume. If you have 10+ high-traffic surfaces, Pro is $240/year, which most production sites can absorb.
Read more
- The site this came from: DooDoo.Love, 6,800+ free HTML5 browser games.
- Earlier in this debugging arc: Why We Banned 'Within the Realm of...' From Our AI Game Descriptions — the AI doorway story.
About the author: Steuber Alberto is Editor-in-Chief at DooDoo.Love. Reach me at support@doodoo.love.
Top comments (0)