Origin Shield Hit Ratio vs Purge Latency

A shield tier is a cache in front of your origin that every PoP misses into instead of hitting the origin directly. It buys origin offload measured in orders of magnitude, and it charges for that in one currency: a purge now has one more tier to clear before the edge can serve the new document.

For fingerprinted assets the trade is close to free — hashed files are never purged, so the shield’s hit ratio is pure profit. For the HTML entry points it is a real decision, because those are the objects a deploy invalidates, and their propagation time is now the sum of two tiers rather than one.

What the Shield Actually Changes

On an edge cache hit nothing changes at all: the response never leaves the PoP, and 98% or more of hashed-asset traffic lands there. The shield only participates on a miss, and that is where the two effects appear — a shorter fetch for most misses, a longer chain for every purge.

Miss path with and without a shield Without a shield every cold PoP fetches directly from the origin bucket across a long round trip. With a regional shield the PoP fetches from a nearby shield, and only the shield's own miss reaches the origin. The shield adds one hop on a miss and removes many origin fetches No shield Edge PoP miss 142 ms RTT Origin bucket us-east-1 Every cold PoP pays the full transatlantic round trip With a regional shield Edge PoP miss 12 ms Shield regional 142 ms Origin bucket us-east-1 One PoP pays the rest hit On an edge hit, which is most hashed-asset traffic, neither path is used
The shield converts N slow origin fetches into one slow fetch plus N fast regional ones.

Comparing the Three Topologies

The numbers below assume a delivery profile of 20,000 requests per second at the edge, a 96% edge hit ratio, an origin in us-east-1, and user traffic spread across North America, Europe, and Asia-Pacific. Substitute your own edge hit ratio — the shape of the answer does not change, only the magnitudes.

Dimension No shield Four regional shields One global shield
Requests reaching the next tier 800/s 800/s 800/s
Shield hit ratio 80% 85%
Origin requests per second 800 160 120
Median first byte, edge miss + tier hit 142 ms 30 ms 24 ms (near) / 210 ms (far)
Median first byte, edge miss + tier miss 142 ms 154 ms 166 ms
HTML purge propagation ~0.9 s ~1.3 s ~1.4 s
Extra egress billed none shield → edge shield → edge
Failure domain one PoP one region everything
Best when origin sits near your users traffic is genuinely multi-region origin is slow or metered hard

The regional and global rows differ less in offload than people expect and more in tail latency. A single global shield aggregates every miss in the world into one cache, so it holds the longest tail of rarely requested chunks — but a Sydney PoP missing into a Virginia shield pays a 210 ms hop that a regional shield would have served in 30 ms. Cloudflare’s Tiered Cache exposes exactly this choice as Smart, Regional, and Generic Global topologies; the propagation behaviour of each is measured in tiered cache and hashed asset propagation.

Working the Hit-Ratio Arithmetic

Two caches in series multiply their miss ratios. That single fact is the whole model:

origin_rps = edge_rps × (1 − edge_hit_ratio) × (1 − shield_hit_ratio)

With edge_rps = 20000, edge_hit_ratio = 0.96, and no shield:

origin_rps = 20000 × 0.04 × 1.00 = 800

Add a shield holding 85% of what it is asked for:

origin_rps = 20000 × 0.04 × 0.15 = 120

The combined offload — the share of edge requests that never touch the origin — is 1 − (0.04 × 0.15) = 0.994, or 99.4%, against 96% with no shield. Framed as a hit ratio the improvement looks like rounding noise. Framed as origin load it is a 6.7× reduction, and that is the number your origin’s autoscaler, connection pool, and egress bill actually experience. Never evaluate a shield on the hit-ratio delta; evaluate it on the miss-count ratio.

The deploy-time picture is more dramatic, because a deploy is a synchronised cold-cache event across every PoP at once. A build emitting 300 new hashed chunks, delivered by two providers with roughly 40 active PoP regions each, produces up to 300 × 40 × 2 = 24,000 origin fetches as caches warm. One shared shield collapses that to 300 — every other request is served from a tier that already has the object.

Origin fetches per deploy Bar chart comparing origin fetches while caches warm after a deploy of three hundred new chunks: twenty-four thousand with no shield, two thousand four hundred with four regional shields, and three hundred with one global shield. Origin fetches while caches warm after one deploy No shield 24,000 fetches Regional shields 2,400 fetches One global shield 300 fetches Two providers, 40 PoP regions each, 300 new hashed chunks per build
Deploy-time origin load, not steady-state hit ratio, is what usually justifies the shield tier.

Measure your own shield hit ratio from its access log rather than trusting a dashboard average, which usually blends HTML and assets into one meaningless number:

# Nginx shield: log_format must include $upstream_cache_status
# log_format shield '$request_uri $upstream_cache_status $request_time';

awk '$1 ~ /\/assets\// { total++; if ($2 == "HIT") hits++ }
     END { printf "asset hit ratio: %.4f  (%d/%d)\n", hits/total, hits, total }' \
  /var/log/nginx/shield.log

awk '$1 !~ /\/assets\// { total++; if ($2 == "HIT") hits++ }
     END { printf "html hit ratio:  %.4f  (%d/%d)\n", hits/total, hits, total }' \
  /var/log/nginx/shield.log

# asset hit ratio: 0.9871  (394812/399978)
# html hit ratio:  0.4402  (12043/27358)

The split matters. An asset hit ratio of 98.7% at the shield means the shield is doing its job; an HTML hit ratio of 44% is normal and irrelevant, because HTML is short-lived by design. If your asset hit ratio at the shield is below about 95%, the shield is too small for your working set — raise max_size and inactive before concluding that shields do not help.

Why Regional and Global Shields Land on Different Ratios

The gap between 80% and 85% is not a vendor characteristic, it is arithmetic on request aggregation. A cache’s hit ratio over a large catalogue of rarely requested objects rises with the number of requests it sees per object per retention window. Split the same 800 misses per second across four regional shields and each one sees 200/s; a chunk requested twice a minute globally is requested once every two minutes at each regional shield, which is still inside a 30-day retention window but well outside the window for anything evicted under memory pressure.

Concretely, with four regional shields at 80%: 800 × 0.20 = 160 origin requests per second. With one global shield at 85%: 800 × 0.15 = 120. The global tier wins by 40 requests per second — about 25% less origin load — and loses roughly 180 ms of first-byte time for the PoPs furthest from it. If your origin is comfortable at 160 requests per second, take the regional topology and keep the latency. If it is not, the global shield buys you headroom that no amount of edge tuning will.

The Cost Side of the Ledger

Shield egress is billed twice for the same bytes: origin to shield, then shield to edge. At a typical object-storage egress rate and a 300 KB average chunk, the 300 fetches of a fresh deploy move about 90 MB from the bucket — negligible. Steady state is where it adds up: every edge miss that the shield serves is billed as shield egress instead of origin egress, and if you run the shield yourself on a compute instance you are also paying for the instance and its disk.

The comparison that matters is shield cost against origin cost at the traffic level you are actually removing. Removing 680 origin requests per second (800 down to 120) from a metered origin usually dwarfs the cost of one shield instance per region. Removing 40 requests per second does not. Compute the delta before choosing a topology, and re-compute it after your edge hit ratio improves — a shield sized for a 90% edge hit ratio is oversized once the edge reaches 98%.

Aligning Cache Keys Across Both Providers

A shield only reaches these hit ratios if both providers request the object identically. Two CDNs forwarding the same URL with different headers produce two shield cache entries for one file, halving the hit ratio and doubling the origin fetches — the failure looks like a shield that “does not work” and is actually a cache-key mismatch.

# Normalise everything the two providers might vary on before caching.
map $http_accept_encoding $norm_encoding {
    default        "";
    "~*br"         "br";
    "~*gzip"       "gzip";
}

server {
    listen 443 ssl http2;
    server_name shield.example.com;

    # One entry per URI + negotiated encoding. Nothing provider-specific.
    proxy_cache_key "$scheme$request_method$host$uri$norm_encoding";

    # Strip headers that differ per provider and would otherwise reach the origin.
    proxy_set_header CF-Connecting-IP  "";
    proxy_set_header Fastly-Client-IP  "";
    proxy_set_header X-Forwarded-For   "";
    proxy_set_header Accept-Encoding   $norm_encoding;

    # Refuse to let the origin fragment the cache further.
    proxy_hide_header Vary;
    add_header Vary "Accept-Encoding" always;
}

Two rules follow. Query strings must be excluded from the shield’s key for hashed assets — the filename already carries the version, so app.a1b2c3d4.js?v=2 and app.a1b2c3d4.js are the same bytes and must be the same entry. And Vary must be pinned to a value both providers can satisfy: a Vary: User-Agent leaking from the origin turns one cached chunk into thousands of near-duplicates and is the single fastest way to destroy a shield’s hit ratio.

What the Extra Hop Costs a Purge

Purge propagation is where the shield charges you. The edge cannot serve a new document until the shield has it, so total propagation is the shield’s clearing time plus the edge’s, not the maximum of the two.

Purge propagation with a shield Two timelines. Without a shield, a purge is accepted and the edge PoPs clear, finishing in about nine hundred milliseconds. With a shield, the accepted purge must clear the shield first and the edge second, finishing in about one and a half seconds. A purge must clear every tier before the new build is safe No shield purge accepted edge PoPs clear total 900 ms With a shield purge accepted shield clears edge clears total 1.4 s The edge cannot serve the new document until the shield has it
Tiers add, they do not overlap. Budget the sum when you set a deploy gate's timeout.

Measure the real number for your topology rather than assuming the vendor’s published figure. This script purges a canary URL and polls until the change is visible at both tiers:

#!/usr/bin/env bash
# scripts/measure-propagation.sh — how long until a purge is visible at each tier
set -euo pipefail

HOST=www.example.com
EDGE_IP=203.0.113.10
SHIELD=https://shield.example.com
CANARY=/canary.txt
STAMP=$(date +%s%N)

# 1. Write a new value at the origin.
printf '%s\n' "$STAMP" | aws s3 cp - "s3://assets-origin${CANARY}" \
  --content-type text/plain --cache-control "no-cache" >/dev/null

# 2. Purge both tiers, shield first.
curl -sf "${SHIELD}/purge${CANARY}" >/dev/null
curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" -H "Content-Type: application/json" \
  --data "{\"files\":[\"https://${HOST}${CANARY}\"]}" >/dev/null

START=$(date +%s%N)
poll() {
  local label="$1" url="$2" resolve="${3:-}"
  while true; do
    if [ -n "$resolve" ]; then
      body=$(curl -s --resolve "$resolve" "$url")
    else
      body=$(curl -s "$url")
    fi
    if [ "$body" = "$STAMP" ]; then
      printf '%-8s visible after %d ms\n' "$label" $(( ($(date +%s%N) - START) / 1000000 ))
      return 0
    fi
    sleep 0.1
  done
}

poll shield "${SHIELD}${CANARY}"
poll edge   "https://${HOST}${CANARY}" "${HOST}:443:${EDGE_IP}"

# shield   visible after 214 ms
# edge     visible after 1382 ms

Run it a dozen times and take the 95th percentile, not the median — the number you need is the one that decides whether a deploy gate is flaky. If the edge figure is consistently far above the shield figure plus the vendor’s published propagation time, the likely cause is purge ordering: an edge purged before the shield re-pulls the stale object and only clears on its next natural expiry. The correct sequencing is described in the multi-CDN purge orchestration guide.

When to Reconsider the Shield

Your origin is already at the edge. If HTML and assets are served from a Worker, a Function, or object storage replicated to every region, the shield’s fetch saving is small and its purge cost is not. Skip it.

You purge HTML constantly. A site deploying twenty times a day with a 30-second HTML TTL spends more time in propagation windows than a shield’s offload is worth. Either lengthen the TTL and rely on purge, or shorten it and rely on expiry — the trade is analysed in immutable Cache-Control and TTL tuning.

Your working set does not fit. A shield with a hit ratio under about 60% on assets is adding a hop for nothing. Fix the sizing or remove the tier.

You cannot tolerate a shared failure domain. In a two-provider setup, one shared shield is a component whose failure takes both providers’ misses with it — precisely the correlation a second provider was bought to avoid. Either give each provider its own shield or accept that the shield, not the CDN, is now your availability ceiling. That constraint interacts directly with a cutover, as DNS failover and orphaned fingerprinted assets sets out.

Conversely, keep the shield if hashed assets dominate your traffic and HTML is a rounding error in request volume — which is the normal shape of a fingerprinted site. Then the shield’s cost applies to a handful of purged documents per deploy and its benefit applies to everything else.