Measuring Origin Offload After Enabling Tiered Caching
After working through this guide you will be able to define origin offload and cache hit ratio precisely enough that the two stop being confused, pull the raw counts from provider analytics and from your own origin access logs, normalize a before/after comparison so a traffic swing does not masquerade as a result, isolate the objects that never benefit, and convert the whole thing into a keep, retune, or revert decision you can defend in a review.
Turning on an upper tier is a five-minute change. Proving it worked is the part that goes wrong, usually because someone compares a Tuesday to a Sunday, or reports a hit ratio that barely moved and concludes nothing happened. Tiering does not primarily raise hit ratio — it collapses the number of caches that independently ask your origin for the same object, which is a different number in a different place. The mechanism behind that distinction is described in tiered caching and origin shield; this guide is about the measurement.
Key implementation objectives:
- Separate cache hit ratio from origin offload, and report each by requests and by bytes.
- Extract the counts from the provider’s GraphQL analytics and from origin access logs, and reconcile the two.
- Normalize the comparison against traffic volume and mix so the delta reflects the change, not the week.
- Identify the cold objects that never hit, and decide between keeping, retuning the cache key, or reverting.
Prerequisites
You need: read access to your CDN analytics API (a Cloudflare token with Zone → Analytics → Read, or CloudWatch/AWS/CloudFront metrics, or Fastly’s stats API); shell access to origin access logs or the log store they ship to; and a baseline captured before enablement covering at least one full weekly cycle. If you do not have the baseline, stop here and collect one — a post-hoc comparison against a different month is not evidence.
export CF_API_TOKEN="…"
export ZONE_ID="…"
jq --version # jq-1.6+
Definitions that decide the whole measurement
Four numbers, and every argument about tiering comes from mixing them up.
Edge requests — everything the CDN received. Edge hits — the subset the edge served from its own storage. Origin requests — what your origin actually logged. Origin bytes — what your origin actually sent.
cache_hit_ratio = edge_hits / edge_requests
origin_offload = 1 - (origin_requests / edge_requests)
byte_offload = 1 - (origin_bytes / edge_bytes)
Cache hit ratio describes the edge experience and is mostly a story about your TTLs and cache key. Origin offload describes the origin experience and is the number tiering moves. They are not the same fraction: a 96% hit ratio and a 96% offload look identical, but the first counts responses served from the edge, and the second counts requests your origin never saw — which includes tier fills that were never edge hits in the first place.
Reporting both by requests and by bytes is not pedantry. Request offload is dominated by small assets that are trivially cacheable; byte offload is dominated by a handful of large media objects. A change that fixes caching for a 4 MB video segment barely moves request offload while transforming byte offload and your egress bill, and vice versa.
Step-by-step procedure
Step 1 — Pull the edge counts from provider analytics
Cloudflare’s GraphQL Analytics API gives requests and bytes grouped by cache status, which is exactly the breakdown you need. The httpRequestsAdaptiveGroups dataset is sampled but carries a sample interval you can correct for.
read -r -d '' QUERY <<'GQL'
query ZoneCache($zone: String!, $start: Time!, $end: Time!) {
viewer {
zones(filter: {zoneTag: $zone}) {
httpRequestsAdaptiveGroups(
limit: 100
filter: {datetime_geq: $start, datetime_lt: $end}
dimensions: [cacheStatus]
) {
count
sum { edgeResponseBytes }
dimensions { cacheStatus }
}
}
}
}
GQL
curl -s https://api.cloudflare.com/client/v4/graphql \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$(jq -n --arg q "$QUERY" --arg z "$ZONE_ID" \
'{query:$q, variables:{zone:$z, start:"2026-07-14T00:00:00Z", end:"2026-07-21T00:00:00Z"}}')" \
| jq -r '.data.viewer.zones[0].httpRequestsAdaptiveGroups[]
| [.dimensions.cacheStatus, .count, .sum.edgeResponseBytes] | @tsv'
Expected shape of the output — one row per status, which you then fold into hits and misses:
hit 396120448 28914455102
miss 11204118 4402188310
expired 1930442 612004881
dynamic 4820117 288100442
revalidated 1421905 102884330
Treat hit, revalidated, and stale serving as hits; treat miss and expired as misses. dynamic and bypass never touched the cache and should be reported separately rather than folded into either — including them silently deflates your hit ratio and makes tiering look ineffective.
Step 2 — Pull the origin counts from your own logs
The CDN cannot tell you what your origin experienced; only your origin can. Aggregate requests and megabytes per hour straight from the access log so you can line the series up against the enablement timestamp:
# nginx combined format: $4 = [dd/Mon/yyyy:HH, $10 = body_bytes_sent
awk '{ split($4, t, ":"); k = substr($4,2) ; k = t[1] ":" t[2] ":00";
req[k]++; b[k] += $10 }
END { for (k in req) printf "%s\t%d\t%.1f\n", k, req[k], b[k]/1048576 }' \
/var/log/nginx/access.log \
| sort | tail -48
[14/Jul/2026:09:00 198442 2841.6
[14/Jul/2026:10:00 201880 2903.2
[21/Jul/2026:09:00 41207 612.4
[21/Jul/2026:10:00 42990 638.1
Two sanity checks before you trust these numbers. First, confirm the log includes all origin traffic, not just the vhost behind the CDN — health checks, internal calls, and a second hostname sharing the server will inflate the count. Second, exclude synthetic monitoring, which is constant and therefore looks like a fixed floor that no amount of caching removes.
Step 3 — Normalize before you compare
Raw origin request counts are contaminated by traffic growth, seasonality, and campaign spikes. Divide by edge requests to get a rate that is comparable across weeks:
origin_requests_per_million_edge = origin_requests / (edge_requests / 1e6)
Then compare like windows: same days of week, same hours, same length, and both windows free of deploys and incidents. A worked example from a catalog site that enabled a single upper tier between the two windows:
| Metric | Before (Tue–Mon) | After (Tue–Mon) | Change |
|---|---|---|---|
| Edge requests | 412,600,000 | 428,900,000 | +3.9% |
| Cache hit ratio (requests) | 96.1% | 96.4% | +0.3 pp |
| Cache hit ratio (bytes) | 91.2% | 91.6% | +0.4 pp |
| Origin requests | 16,090,000 | 3,240,000 | −79.9% |
| Origin bytes | 4.81 TB | 1.14 TB | −76.3% |
| Origin requests per million edge | 39,001 | 7,554 | −80.6% |
| p95 concurrent origin connections | 340 | 78 | −77.1% |
| p75 TTFB on a MISS | 210 ms | 228 ms | +18 ms |
The hit ratio moved 0.3 percentage points, which on its own reads as noise. The normalized origin rate fell by 80%, and peak origin concurrency — the number that decides whether a deploy takes the site down — fell by a comparable amount. That is the result, and it is invisible if hit ratio is the only metric on the dashboard.
Step 4 — Find the objects that never hit
Some objects are requested so rarely that they miss everywhere no matter what you do, and a tier does not help them. Others miss because their cache key is fragmented, and those are fixable. Separate the two by counting origin requests per distinct URL:
awk '{print $7}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -30
A URL with hundreds of origin requests per day after enablement is not a cold object — it is a key problem. Check whether the same path appears with varying query strings, or whether the response carries a Vary header that fragments it. The fixes are in normalizing query strings in the cache key. Conversely, thousands of distinct URLs each with one or two origin requests per day is the genuinely cold portion of your catalog, and the only levers there are longer TTLs and prefetch, not tiering.
Step 5 — Guard the measurement window
Open the after window at least one full edge-TTL cycle past the enablement timestamp. Immediately after the switch, objects are still resident at the edges from the pre-tier era, so origin fetches are artificially low; a day later they have all expired and refilled through the tier, and the number stabilizes. Measuring in that first hour flatters the result.
Verification
Two checks confirm your numbers are real rather than an artifact of where you looked.
Reconcile edge misses against origin requests. If tiering is working, origin requests should be substantially lower than edge misses, because most misses are filled by the tier:
# From the GraphQL output and the origin log totals
python3 - <<'PY'
edge_requests = 428_900_000
edge_misses = 13_134_560 # miss + expired
origin_requests = 3_240_000
print(f"hit ratio {1 - edge_misses/edge_requests:.4%}")
print(f"origin offload {1 - origin_requests/edge_requests:.4%}")
print(f"tier absorbed {1 - origin_requests/edge_misses:.2%} of edge misses")
PY
hit ratio 96.9375%
origin offload 99.2446%
tier absorbed 75.33% of edge misses
A “tier absorbed” figure near zero means the tier is in the path but not caching — almost always a cache-key problem. A figure above 95% is excellent and usually means your object catalog is small relative to tier storage.
Confirm the fill source directly. Cross-check with the provider’s own tier signal — Cloudflare’s CacheTieredFill Logpush field, Fastly’s two-POP X-Served-By, CloudFront’s multi-part X-Amz-Cf-Id. If the ratio math says the tier is absorbing misses but the fill signal says otherwise, trust the fill signal and re-examine the windows.
Troubleshooting the statistics
Sampled analytics inflate or deflate the delta
Cloudflare’s adaptive datasets are sampled under load, and the sample rate itself varies with traffic. Comparing a heavily sampled peak week against a lightly sampled quiet week introduces error in the direction you least expect. Always read the sample interval alongside the counts and, where the API offers an unsampled dataset for the same dimension, prefer it. Origin logs are unsampled by construction, which is why they are the tiebreaker.
The window is too short to contain a full cycle
A 24-hour window straddles one weekday pattern and no weekend. Since object popularity and deploy cadence both follow a weekly rhythm, anything shorter than seven days will produce a delta dominated by the day you happened to pick. Use whole weeks aligned to the same weekday boundary, and never compare a holiday week to a normal one.
A deploy purged everything mid-measurement
A full purge resets every tier and forces a refill wave, so origin requests spike for the following TTL period. If a purge landed inside either window, the window is unusable — discard it rather than trying to subtract the spike, because the elevated rate persists for as long as it takes the catalog to refill. Deploys that purge by tag rather than everything cause a much smaller, more localized distortion; that is one more argument for cache tags and surrogate-key purging.
Traffic mix shifted between the windows
A marketing push that sends a million visitors to three landing pages raises hit ratio and offload without any configuration change, because the traffic became more concentrated. Guard against it by checking that the share of requests going to your top 100 URLs is comparable across windows. If it moved by more than a few points, segment the comparison by content type and compare within each segment instead of in aggregate.
Byte offload and request offload disagree
This is usually correct rather than a bug. If request offload improved and byte offload did not, your large objects are still missing — check for range requests being fragmented at the tier, or media that is excluded from caching entirely. If byte offload improved and request offload did not, small API responses are dominating the request count and are probably uncacheable for legitimate reasons. Report both and explain the gap rather than picking the flattering one.
Turning the numbers into a decision
Set the thresholds before you look at the data so the decision is not retrofitted to the result. Reasonable defaults: keep if normalized origin requests fell by 25% or more and p75 MISS latency rose by less than 30 ms; retune if one of those two held but not the other; revert if neither did. Retuning means fixing the cache key first and re-measuring, and only then reconsidering placement — key fragmentation explains far more disappointing results than tier location does. Whatever you decide, keep the normalization work; the origin-requests-per-million-edge-requests series is the metric worth putting on a permanent dashboard, and it stays useful long after this particular change is settled.
Frequently Asked Questions
Why did my cache hit ratio barely move after enabling tiered caching? Because tiering does not target hit ratio. Visitors were already being served from the edge most of the time; what changes is how many separate caches ask origin to fill a miss. Expect hit ratio to move a fraction of a percentage point and origin request volume to fall by a large multiple, and report the second number as the result.
Should I measure offload by requests or by bytes? Both, always. Request offload tracks the load on your application servers and database; byte offload tracks your egress bill and origin bandwidth. They diverge whenever large objects behave differently from small ones, and reporting only the flattering one is how caching regressions hide.
How long should I wait after enabling before measuring? At least one full edge-TTL cycle before the window opens, so that objects cached under the old fill path have expired and refilled through the tier, and then a full seven days of measurement. Measuring in the first hour shows an artificially low origin rate that will not hold.
A deploy purged the cache during my measurement window. Can I correct for it? No, discard the window. A full purge forces a refill wave whose elevated origin rate persists for as long as the catalog takes to repopulate, and there is no clean way to subtract it. Re-run the measurement over a period with no full purge, and consider moving to tag-scoped purging so future deploys distort less.
What if origin requests dropped but MISS latency got noticeably worse? That is a placement problem, not a caching problem: the upper tier is far from your origin, so every miss makes a detour. Move the tier closer to the origin — a different shield region on CloudFront, a different POP on Fastly, or the smart topology on Cloudflare — and re-measure before considering a revert.
Related
- Enabling Tiered Cache and Origin Shield on Cloudflare
- Normalizing Query Strings in the Cache Key
- Using Cache Tags for Surrogate-Key Purging
Back to Tiered Caching & Origin Shield