Measuring Cloudflare Cache Hit Ratio per Host
Before the platform I work on moved to Fastly, it sat behind Cloudflare: dozens of Drupal sites, many of them in the same Cloudflare zone. The goal was to improve caching, and the first problem was measuring it properly.
The dashboard shows a "percent cached" figure per zone. That is fine when one zone is one site. But when a zone holds a dozen hosts, the zone number is dominated by the busiest one. A big site at 98% can hide three small ones at 40%. I needed one number per host, for any time window, for all hosts at once, in a format I could put in a spreadsheet and compare week to week.
So I wrote cloudflare-percent-cached, a small Node.js CLI. With per-host numbers to work from, the platform went from an 82% cache hit ratio to 96%, and origin requests dropped by roughly 78%.
The data is in the GraphQL Analytics API
Cloudflare exposes request analytics through a GraphQL endpoint. The dataset you want is httpRequestsAdaptiveGroups, which lets you filter by host and group by cache status. One query per host returns a handful of rows: how many requests had each status in the window.
query GetCachePerHost(
$zoneTag: string
$host: string
$sinceTime: Time
$untilTime: Time
) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequestsAdaptiveGroups(
limit: 10000
filter: {
AND: [
{ datetime_geq: $sinceTime, datetime_leq: $untilTime }
{ requestSource: "eyeball" }
{ clientRequestHTTPHost: $host }
]
}
) {
dimensions { cacheStatus }
count
}
}
}
}Two filters are worth explaining. clientRequestHTTPHost is what makes this per host instead of per zone. And requestSource: "eyeball" keeps only requests from real clients. Without it, requests made by Workers and other Cloudflare-internal sources are counted too, and the number stops matching what visitors experience.
The response is short. It looks something like this:
[
{ "dimensions": { "cacheStatus": "hit" }, "count": 912345 },
{ "dimensions": { "cacheStatus": "miss" }, "count": 40211 },
{ "dimensions": { "cacheStatus": "dynamic" }, "count": 18034 },
{ "dimensions": { "cacheStatus": "expired" }, "count": 6120 },
{ "dimensions": { "cacheStatus": "none" }, "count": 2410 }
]Deciding what counts as "cached"
This is the part that matters more than the code. Cache hit ratio is a fraction, and you have to decide what goes on top and what goes on the bottom.
- On top: by default only
hit. You could argue for countingstaleorrevalidatedtoo, since the origin did little or no work for them. - On the bottom: every request except those with status
none. Those are responses Cloudflare generated itself, such as redirects or blocked requests, which never had a chance to be cached. Leaving them in drags the ratio down for reasons that have nothing to do with caching.
Rather than hard-code one opinion, the CLI takes both lists as options, with "hit" and "none" as defaults:
let total = 0;
let cached = 0;
for (const row of groups) {
const status = row.dimensions.cacheStatus;
if (include.includes(status)) cached += row.count;
if (!exclude.includes(status)) total += row.count;
}
const percentCached = total > 0 ? (cached / total) * 100 : 0;Whatever you choose, choose it once and keep it. A ratio is only useful if this week's number was computed the same way as last week's.
Using it
The CLI takes either a predefined window (30 minutes up to 30 days) or an exact start and end date. The exact dates turned out to be the most useful option. They let me compare the same week before and after a change instead of "the last 7 days" at two random moments.
# one host, last 24 hours
cfpc -z="<zone id>" -h="www.example.com" -t="24 hours"
# a fixed window, for before/after comparisons
cfpc -z="<zone id>" -h="www.example.com" \
-s="2025-01-01T00:00:00Z" -u="2025-01-31T23:59:59Z"
# every site in a CSV, results to another CSV
cfpc -f="sites.csv" -t="7 days" -o="results.csv"For the whole platform, a CSV lists every zone and host:
zoneId,host
<zone id 1>,www.brand-one.com
<zone id 1>,www.brand-two.com
<zone id 2>,www.brand-three.comThe output CSV has the hit ratio, miss ratio, total requests and cached requests per host. Sorting it by total requests and then by hit ratio gives you a list ordered by where an improvement saves the most origin traffic.
What the numbers showed
Per-host numbers changed the conversation. Instead of "caching is at 82%", it became "these few hosts are well below the rest, and here is how much origin traffic they cause". That turns a vague goal into a short list of concrete things to look at, one host at a time, and each fix shows up in the next week's CSV.
The same habit carried over when the platform moved to Fastly. I wrote about that in Migrating a CDN from Cloudflare to Fastly.
If you build your own
- Create an API token with only Analytics Read on the zones you need. Nothing more.
- The adaptive datasets are sampled on high-traffic zones. The ratio is reliable, but don't expect the request counts to match your origin logs exactly.
- The query returns one row per cache status, so
limit: 10000is far more than you will ever need. If you add more dimensions, such as path or content type, the row count grows quickly. Watch the limit then. - How far back you can query depends on your plan. Check that before you promise someone a year-over-year chart.