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.

The four counts and the two ratios they produce Edge requests reduce to edge misses after hits are served, and edge misses reduce to origin requests after tier fills. Cache hit ratio and origin offload are computed from different pairs of those counts. Edge requests everything the CDN saw minus hits Edge misses need a fill from upstream minus tier fills Origin requests what origin logged cache hit ratio = edge hits / edge requests moves with TTLs and cache key offload = 1 - origin req / edge req this is the number tiering moves Report both by requests and by bytes — they diverge whenever large objects miss

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

Choosing a clean measurement window A before window and two candidate after windows on a timeline. The first after window contains a deploy purge and must be discarded; the second is clean. deploy purge Before window 7 full days, tiering off no incidents, no releases usable baseline After, contaminated purge empties every tier mid-window discard it After, clean same weekdays, same length compare this one week 1 week 2 week 3 Allow at least one full TTL cycle after enablement before the after window opens

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

Keep, retune, or revert quadrant A quadrant chart with normalized origin requests on the horizontal axis and miss latency on the vertical axis, mapping the four outcomes to keep, retune placement, retune the cache key, and revert. Retune placement offload is good but the tier sits far from the origin Revert slower misses and no reduction in origin work Keep origin work down, miss latency unchanged Retune the cache key nothing collapsed because keys are too specific origin requests per million edge requests, after / before much lower unchanged MISS latency worse MISS latency flat

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.

Back to Tiered Caching & Origin Shield