Multi-CDN and Origin Shield Purging
The moment a second CDN or a shield tier enters the request path, “purge the cache” stops being one API call and becomes a distributed write that has to succeed on every provider or be retried until it does.
This guide covers the delivery topology that fingerprinted assets make possible — two independent CDNs pulling from one shield tier in front of one asset origin — and the purge orchestration that keeps them consistent. Every technique here assumes filename-based content hashing, because without it a multi-CDN purge is an invalidation problem multiplied by the number of providers, and with it there is almost nothing left to invalidate.
When a Second Provider Is Worth the Purge Complexity
Adding a provider is a resilience and cost decision, not a performance one. The purge surface it creates is the price. Compare honestly against the single-CDN baseline before committing:
| Dimension | Single CDN | Single CDN + shield tier | Two CDNs + shared shield |
|---|---|---|---|
| Purge calls per deploy | 1 | 2 (shield, then edge) | 3 (shield, then edge A, edge B) |
| Failure modes to handle | API error | API error, ordering | API error, ordering, partial fan-out |
| Origin requests during a cold deploy | High | Low | Low |
| Provider outage exposure | Total | Total | Partial, if steering is automated |
| Time to switch away from a bad provider | Hours (DNS + config) | Hours | Seconds to minutes |
| Config drift risk | None | Low | High — two rule sets to keep aligned |
| Recommended when | One region, cost-sensitive | Origin is expensive or slow | Availability target above one provider’s SLA |
If your reason for a second provider is “our CDN had an incident last quarter”, the honest alternative is a documented, rehearsed migration runbook on one provider. Two providers only pay off when the failover is automated and continuously exercised with real traffic — a standby you never send traffic to is a standby whose cache is cold and whose config is stale.
Why Fingerprinting Makes Multi-CDN Tractable
On a site with unhashed asset filenames, a two-provider setup doubles every invalidation: app.js has to be evicted from provider A’s edge, provider B’s edge, and the shield, and any PoP that misses the eviction serves last week’s JavaScript against this week’s HTML. With content-hashed filenames, app.a1b2c3d4.js is a new URL that has never been cached anywhere. Nothing to evict, nothing to race.
That reduces the entire multi-CDN purge problem to one class of object: the HTML entry points that reference the hashes. A typical single-page app has one HTML document. A server-rendered site might have a few hundred routes. Either way, the mutable surface is small, enumerable, and identical on both providers — which is exactly the property that lets you treat the fan-out as a single transaction.
The shield tier is what makes this affordable. Without it, every cold PoP on both providers reaches your bucket directly, and a deploy that adds 400 new chunks produces 400 requests per PoP-region per provider. With a shield, the first PoP to miss pays the origin fetch and every other PoP on either provider is served from the shield. Cloudflare’s equivalent is Tiered Cache, described in the tiered cache propagation walkthrough; Fastly calls it shielding and pins it to a named POP; CloudFront exposes it as Origin Shield on the origin definition. The hit-ratio versus purge-latency analysis works the arithmetic for each topology.
Prerequisites
- Node.js 20.6 or newer for the orchestrator below — it uses native
fetch,AbortSignal.timeout(), and top-levelawaitin ESM. Node 18 works if you replaceAbortSignal.timeout()with a manualAbortControllertimer. - One asset origin. Both providers must be configured with the same origin host and the same path prefix. Two buckets kept in sync by a replication job is the single most common source of the failures described in DNS failover and orphaned assets.
- Purge credentials for every provider, each with the narrowest scope that works: a Cloudflare API token with
Cache Purgeon one zone, a Fastly token withpurge_allon one service, an IAM role limited tocloudfront:CreateInvalidationon one distribution. - A deploy identifier — a git SHA or monotonically increasing build number — emitted into the HTML and into a manifest. The orchestrator uses it as an idempotency key and the reconciliation step uses it to tell builds apart.
- Fingerprinted output with 8-hex hashes (
[contenthash:8]in Webpack 5,[hash:8]in Vite 5 and Rollup 4). Monorepos emitting thousands of chunks should widen to 12–16 hex characters to keep the collision probability negligible.
Configuration Reference
The orchestrator reads one JSON file. These are its keys:
| Key | Type | Default | Effect |
|---|---|---|---|
deployId |
string | (required) | Idempotency key sent with every request and asserted during reconciliation |
urls |
string[] | (required) | Absolute HTML entry-point URLs to purge — assets are never listed here |
shield.kind |
"nginx" / "none" |
"none" |
Selects the shield purge adapter; none skips the shield stage entirely |
shield.purgeEndpoint |
string | — | Internal URL of the shield’s purge listener, reachable only from CI |
providers[].id |
string | (required) | Stable name used in logs and in the reconciliation report |
providers[].kind |
"cloudflare" / "fastly" |
(required) | Selects the API adapter |
providers[].zone |
string | (required) | Cloudflare zone ID or Fastly service ID |
providers[].tokenEnv |
string | (required) | Name of the environment variable holding the token — never the token itself |
providers[].surrogateKey |
string | — | Fastly only: purges by key instead of by URL |
providers[].maxAttempts |
integer | 5 |
Attempts per call before the provider branch is declared failed |
providers[].timeoutMs |
integer | 8000 |
Per-attempt timeout; a hung provider must not stall the deploy |
providers[].concurrency |
integer | 4 |
In-flight requests per provider, kept under the published rate limit |
overlapSeconds |
integer | 900 |
How long both builds’ assets must remain resolvable at the origin |
reconcileAfterMs |
integer | 5000 |
Delay before the post-purge consistency check runs |
failureMode |
"block" / "warn" |
"block" |
Whether a failed branch fails the deploy step |
Keep failureMode at block. A purge that half-succeeded and exited zero is the exact condition that produces the symptom in purging two CDNs without a split-brain cache.
Step-by-Step Implementation
1. Pin Both Providers to One Asset Origin
Both CDNs must name the same origin host, and that host should be the shield rather than the bucket. An Nginx shield in front of object storage looks like this:
# /etc/nginx/conf.d/asset-shield.conf
proxy_cache_path /var/cache/nginx/assets levels=1:2 keys_zone=assets:256m
max_size=200g inactive=30d use_temp_path=off;
upstream asset_bucket {
server assets-origin.internal:443;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name shield.example.com;
# Hashed assets: cache in the shield effectively forever.
location ~* "^/assets/.*\.[a-f0-9]{8,16}\.(js|css|woff2|png|webp|svg)$" {
proxy_pass https://asset_bucket;
proxy_cache assets;
proxy_cache_valid 200 365d;
proxy_cache_key "$scheme$request_method$host$uri";
proxy_cache_lock on;
proxy_cache_use_stale updating error timeout;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Shield-Cache $upstream_cache_status always;
}
# HTML: short shield TTL, and purgeable.
location / {
proxy_pass https://asset_bucket;
proxy_cache assets;
proxy_cache_valid 200 60s;
proxy_cache_key "$scheme$request_method$host$uri";
add_header Cache-Control "no-cache" always;
add_header X-Shield-Cache $upstream_cache_status always;
}
# Purge listener, reachable only from the deploy network.
location ~ /purge(/.*) {
allow 10.0.0.0/8;
deny all;
proxy_cache_purge assets "$scheme$request_method$host$1";
}
}
proxy_cache_lock on is doing real work here: when both providers’ PoPs miss the same new hash simultaneously, Nginx collapses them into one upstream request instead of a stampede against the bucket. X-Shield-Cache is the header the verification step below reads to prove which tier answered.
2. Describe the Fan-Out as Data
{
"deployId": "2f9c41a7",
"urls": [
"https://www.example.com/",
"https://www.example.com/pricing/",
"https://www.example.com/docs/"
],
"shield": {
"kind": "nginx",
"purgeEndpoint": "https://shield.example.com/purge"
},
"providers": [
{
"id": "cdn-a",
"kind": "cloudflare",
"zone": "0123456789abcdef0123456789abcdef",
"tokenEnv": "CF_API_TOKEN",
"maxAttempts": 5,
"timeoutMs": 8000,
"concurrency": 4
},
{
"id": "cdn-b",
"kind": "fastly",
"zone": "SU1Z0isxPaozGVKXdv0eY",
"tokenEnv": "FASTLY_API_TOKEN",
"surrogateKey": "html-entry-points",
"maxAttempts": 5,
"timeoutMs": 8000,
"concurrency": 2
}
],
"overlapSeconds": 900,
"reconcileAfterMs": 5000,
"failureMode": "block"
}
Keeping the topology in a file rather than in shell arguments means the reconciliation step, the failover check, and the incident runbook all read the same source of truth.
3. Order the Fan-Out: Shield First, Always
The shield holds the copy that both edges will pull on their next miss. Purge the edges first and you have created a window in which every edge miss re-populates itself with the stale bytes the shield is still holding — you will have purged the edge into a worse state than before.
There is one exception. Where the shield is provider-managed and the provider’s purge API clears every tier atomically — Cloudflare Tiered Cache and Fastly shielding both behave this way — the ordering collapses to a single call and you do not get to choose. Ordering only matters for a shield you operate, or for a stacked topology where one CDN is configured as another CDN’s origin.
4. Fan Out with Retries and an All-or-Nothing Result
// scripts/purge-orchestrator.mjs
// Node 20.6+. Purges the shield, then every configured CDN, with bounded
// retries. Exits non-zero unless every branch reported success.
import { readFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
const configPath = process.argv[2] ?? 'purge.config.json';
const config = JSON.parse(await readFile(configPath, 'utf8'));
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
function backoffMs(attempt) {
const base = Math.min(250 * 2 ** (attempt - 1), 8000);
return base + Math.floor(Math.random() * 250); // jitter
}
async function callOnce(request, timeoutMs) {
const response = await fetch(request.url, {
method: request.method,
headers: request.headers,
body: request.body,
signal: AbortSignal.timeout(timeoutMs)
});
const text = await response.text();
return { status: response.status, ok: response.ok, body: text.slice(0, 400) };
}
async function callWithRetry(label, request, { maxAttempts, timeoutMs }) {
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const result = await callOnce(request, timeoutMs);
if (result.ok) {
console.log(`[${label}] attempt ${attempt}: ${result.status} OK`);
return { label, ok: true, attempts: attempt, status: result.status };
}
lastError = `HTTP ${result.status} ${result.body}`;
if (!RETRYABLE_STATUS.has(result.status)) break;
} catch (err) {
lastError = err.name === 'TimeoutError' ? 'request timed out' : String(err);
}
console.warn(`[${label}] attempt ${attempt} failed: ${lastError}`);
if (attempt < maxAttempts) await sleep(backoffMs(attempt));
}
return { label, ok: false, attempts: maxAttempts, error: lastError };
}
function chunk(items, size) {
const out = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
// ---- provider adapters -----------------------------------------------------
function cloudflareRequests(provider, urls, deployId) {
const token = process.env[provider.tokenEnv];
if (!token) throw new Error(`${provider.tokenEnv} is not set`);
// Cloudflare accepts at most 30 files per call.
return chunk(urls, 30).map((files, i) => ({
label: `${provider.id}#${i}`,
url: `https://api.cloudflare.com/client/v4/zones/${provider.zone}/purge_cache`,
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Deploy-Id': deployId
},
body: JSON.stringify({ files })
}));
}
function fastlyRequests(provider, urls, deployId) {
const token = process.env[provider.tokenEnv];
if (!token) throw new Error(`${provider.tokenEnv} is not set`);
const headers = {
'Fastly-Key': token,
Accept: 'application/json',
'X-Deploy-Id': deployId
};
if (provider.surrogateKey) {
return [{
label: `${provider.id}#key`,
url: `https://api.fastly.com/service/${provider.zone}/purge/${provider.surrogateKey}`,
method: 'POST',
headers,
body: undefined
}];
}
return urls.map((target, i) => ({
label: `${provider.id}#${i}`,
url: target,
method: 'PURGE',
headers,
body: undefined
}));
}
const ADAPTERS = { cloudflare: cloudflareRequests, fastly: fastlyRequests };
async function runProvider(provider, urls, deployId) {
const build = ADAPTERS[provider.kind];
if (!build) throw new Error(`unknown provider kind: ${provider.kind}`);
const requests = build(provider, urls, deployId);
const limit = provider.concurrency ?? 4;
const options = {
maxAttempts: provider.maxAttempts ?? 5,
timeoutMs: provider.timeoutMs ?? 8000
};
const results = [];
for (const group of chunk(requests, limit)) {
const settled = await Promise.all(
group.map((request) => callWithRetry(request.label, request, options))
);
results.push(...settled);
}
return { provider: provider.id, ok: results.every((r) => r.ok), results };
}
async function runShield(shield, urls, deployId) {
if (!shield || shield.kind === 'none') return { provider: 'shield', ok: true, results: [] };
const requests = urls.map((target, i) => {
const path = new URL(target).pathname;
return {
label: `shield#${i}`,
url: `${shield.purgeEndpoint}${path}`,
method: 'GET',
headers: { 'X-Deploy-Id': deployId },
body: undefined
};
});
const results = [];
for (const request of requests) {
results.push(await callWithRetry(request.label, request, { maxAttempts: 3, timeoutMs: 5000 }));
}
return { provider: 'shield', ok: results.every((r) => r.ok), results };
}
// ---- orchestration ---------------------------------------------------------
const started = Date.now();
const shieldOutcome = await runShield(config.shield, config.urls, config.deployId);
if (!shieldOutcome.ok) {
console.error('shield purge failed — refusing to purge the edges on top of stale bytes');
process.exit(1);
}
const edgeOutcomes = await Promise.all(
config.providers.map((provider) => runProvider(provider, config.urls, config.deployId))
);
const outcomes = [shieldOutcome, ...edgeOutcomes];
const failed = outcomes.filter((o) => !o.ok).map((o) => o.provider);
console.log(JSON.stringify({
deployId: config.deployId,
elapsedMs: Date.now() - started,
outcomes: outcomes.map((o) => ({ provider: o.provider, ok: o.ok, calls: o.results.length }))
}, null, 2));
if (failed.length > 0) {
console.error(`purge incomplete on: ${failed.join(', ')}`);
process.exit(config.failureMode === 'warn' ? 0 : 1);
}
console.log('purge complete on every tier');
Three properties of this script matter more than its shape. Each call is idempotent — purging an already-purged URL is a no-op on every provider, so a retry after an ambiguous timeout is always safe. Each provider branch is independently bounded, so a provider that is rate-limiting you cannot make the other provider’s branch time out. And the exit code is all-or-nothing: a partially successful fan-out fails the deploy rather than leaving two networks disagreeing about which build is current.
Run it with the same working directory as the config:
CF_API_TOKEN="$CLOUDFLARE_PURGE_TOKEN" \
FASTLY_API_TOKEN="$FASTLY_PURGE_TOKEN" \
node scripts/purge-orchestrator.mjs purge.config.json
5. Understand What the Fan-Out Actually Costs
Providers do not answer at the same speed, and the deploy is not finished until the slowest branch returns. Cloudflare’s purge API typically acknowledges in 150–400 ms and propagates within about five seconds; Fastly’s instant purge acknowledges in under 200 ms and is globally effective in roughly 150 ms after that; CloudFront’s CreateInvalidation returns immediately but reports InProgress for 60 seconds to several minutes.
6. Make Every Call Retryable and Every Failure Loud
A purge job has exactly five states, and the useful discipline is refusing to invent a sixth. Nothing in the pipeline may treat “we tried and it did not work” as success.
Wire the orchestrator in as the last step of the deploy job, after the asset sync and after the HTML upload, exactly as the CI/CD asset pipeline guide sequences a single-provider deploy:
# .github/workflows/deploy.yml (excerpt)
- name: Sync fingerprinted assets to the shared origin
run: aws s3 sync dist/assets/ s3://assets-origin/assets/ --cache-control "public,max-age=31536000,immutable"
- name: Upload HTML entry points
run: aws s3 sync dist/ s3://assets-origin/ --exclude "assets/*" --cache-control "no-cache"
- name: Purge every tier
env:
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }}
run: node scripts/purge-orchestrator.mjs purge.config.json
Traffic Steering: DNS Versus Client-Side RUM
How requests are divided between providers determines how a purge failure is felt. Two mechanisms dominate.
DNS steering publishes weighted or health-checked records for the delivery hostname. It is simple and works for every client, but resolver caching means a change takes as long as the record’s TTL — plus whatever recursive resolvers actually honour, which is frequently longer than the TTL you published. A 60-second TTL on the delivery CNAME is the practical floor; anything shorter costs resolution latency without buying much responsiveness.
Client-side steering ships a small script that measures real-user timings against both providers and rewrites asset hostnames for subsequent navigations. It reacts in seconds rather than minutes and can shift a single user away from a degraded PoP that a health check from a datacentre would never see. The cost is that the steering logic lives in a script that itself must be delivered by one of the providers, and that a stale steering script can pin users to the wrong provider — which makes the steering script’s own cache lifetime a first-class concern, covered by the tuning rules in immutable Cache-Control and TTL tuning.
Whichever you use, both providers must serve the same hashed asset paths. Steering that also switches the asset hostname turns one cache into two and doubles your origin fetches for identical bytes. Steer the HTML; keep assets on a single hostname pointed at whichever provider you designate as the asset delivery path, or on a hostname that both providers serve from the same shield.
Verification
After the orchestrator exits zero, prove that both providers actually serve the new build. The check is one command per provider against a pinned edge, comparing the deploy identifier the HTML embeds:
#!/usr/bin/env bash
# scripts/verify-fanout.sh — assert both providers serve the same build
set -euo pipefail
DEPLOY_ID="$1"
HOST="www.example.com"
CDN_A_IP="203.0.113.10" # a known PoP address for provider A
CDN_B_IP="198.51.100.20" # a known PoP address for provider B
check() {
local label="$1" ip="$2"
local body
body=$(curl -s --resolve "${HOST}:443:${ip}" "https://${HOST}/")
if grep -q "data-deploy-id=\"${DEPLOY_ID}\"" <<<"$body"; then
echo "ok ${label} serves ${DEPLOY_ID}"
else
echo "FAIL ${label} does not serve ${DEPLOY_ID}" >&2
return 1
fi
}
check "cdn-a" "$CDN_A_IP"
check "cdn-b" "$CDN_B_IP"
# Confirm one hashed asset is byte-identical across both providers.
ASSET="/assets/app.a1b2c3d4.js"
HASH_A=$(curl -s --resolve "${HOST}:443:${CDN_A_IP}" "https://${HOST}${ASSET}" | sha256sum | cut -d' ' -f1)
HASH_B=$(curl -s --resolve "${HOST}:443:${CDN_B_IP}" "https://${HOST}${ASSET}" | sha256sum | cut -d' ' -f1)
if [ "$HASH_A" = "$HASH_B" ]; then
echo "ok ${ASSET} identical on both providers"
else
echo "FAIL ${ASSET} differs: ${HASH_A} vs ${HASH_B}" >&2
exit 1
fi
Inspect the shield’s own view separately — it is the tier most likely to be holding something unexpected:
# Was the HTML served by the shield or fetched fresh from the bucket?
curl -sI "https://shield.example.com/" | grep -i "x-shield-cache\|cache-control\|age"
# x-shield-cache: MISS
# cache-control: no-cache
# The hashed asset should be a shield HIT with a one-year lifetime
curl -sI "https://shield.example.com/assets/app.a1b2c3d4.js" \
| grep -i "x-shield-cache\|cache-control"
# x-shield-cache: HIT
# cache-control: public, max-age=31536000, immutable
Edge Cases and Known Issues
Rate limits are per provider, not per deploy. Cloudflare allows 1,000 purge calls per zone per minute and 30 URLs per call; Fastly caps single-URL purges per service per second and prefers surrogate-key purges for bulk work; CloudFront charges after 1,000 invalidation paths per month and allows three concurrent invalidation batches per distribution. A matrix build that runs the deploy job across several environments in parallel multiplies all of these at once. Serialise the purge stage or move each provider to a key-based purge.
Provider APIs disagree about what “done” means. A Fastly 200 means the object is gone globally. A Cloudflare 200 means the purge was accepted and will complete within seconds. A CloudFront 201 means an invalidation was created and may still be InProgress minutes later — see the CloudFront invalidation reference for the polling loop. Do not compute a single “purge completed” timestamp across providers with different semantics; record each branch’s own completion.
Anycast makes verification hostile. curl https://www.example.com/ from CI resolves to whichever provider DNS steering picks for the CI runner’s location, which is usually one provider and usually one PoP. Pin the address with --resolve as above, or you will verify the same edge twice and declare the fan-out consistent when it is not.
Both providers must agree on compression and Vary. If provider A serves Brotli and provider B serves gzip for the same hashed URL, the bytes on the wire differ even though the file is identical, and the byte-comparison check above fails spuriously. Compare the decoded body — curl --compressed on both sides — or compare the ETag the shield emits rather than the edge response.
The shield is a single point of failure you just created. A shield that is down takes both providers’ misses with it. Run it as at least two instances behind a load balancer, keep proxy_cache_use_stale enabled so a bucket outage does not become an outage, and make sure both CDNs are configured with the bucket as a secondary origin for the case where the shield is unreachable.
Purge tokens outlive the people who made them. A token scoped to Cache Purge on one zone is low-risk; a global API key in a CI secret is a full account compromise waiting for one leaked log line. The orchestrator reads tokens only from named environment variables for exactly this reason — the config file, which is committed, never contains a credential.
Performance Impact
The numbers that matter are origin offload and end-to-end deploy time.
- Origin offload. With no shield, a deploy that publishes 300 new chunks and two providers with roughly 40 active PoP regions each produces up to 24,000 origin fetches as caches warm. One shared shield collapses that to 300 — a 98.75% reduction — because every PoP on both networks resolves its miss at the shield.
- First-byte latency. A shield adds one hop to a cold request. Measured from a European edge to a shield in the same region and a bucket in
us-east-1, the extra hop costs 8–20 ms on a miss and saves the 90–140 ms transatlantic origin round trip on every subsequent miss from any PoP. On a hit at the edge — which is where 98%+ of hashed asset traffic lands — the shield is not in the path at all and costs nothing. - Deploy time. The fan-out adds the slowest branch, not the sum. In practice that is 0.3–1.5 s for a Cloudflare plus Fastly pair on a handful of HTML URLs, and 60 s or more if CloudFront is in the mix and you wait for
Completedrather than acceptingInProgress. - Purge propagation. A shield adds its own propagation step: the edges cannot see fresh content until the shield has it. Budget the shield’s purge latency plus the edge’s, not the larger of the two.
- Cost. Two providers rarely cost 2× — committed-use discounts are per provider, so splitting 70/30 usually loses volume tiers on both. Model the delivery bill at the split you actually intend to run, and include the shield’s egress, which is billed twice: bucket to shield, shield to edge.
FAQ
Do I have to purge fingerprinted assets on both CDNs after a deploy?
No. A fingerprinted asset URL is unique to its content, so the new build’s asset URLs have never been cached on either provider and the old build’s URLs are harmless once no HTML references them. Purge only the HTML entry points. The only exception is a rollback where you re-publish different bytes under a previously used hashed URL — which you should never do; publish a new hash instead.
What happens if the purge succeeds on one provider and fails on the other?
You get a split-brain cache: users routed to the successful provider see the new build, users on the other see the old one, and a user whose HTML came from one provider may request assets that only the other has warmed. Treat the fan-out as all-or-nothing, fail the deploy on any branch failure, and re-run the orchestrator — it is idempotent. The failure mode and its reconciliation check are covered in purging two CDNs without a split-brain cache.
Should each CDN have its own shield, or should they share one?
Share one when your goal is origin offload and your origin is the expensive part; the shared shield gives you a single purge target and one warm copy for both networks. Give each provider its own shield when the providers must be independently survivable — a shared shield is a shared failure domain, which undoes part of the reason you took on a second provider. There is no third answer that gets both properties.
How long do I need to keep the previous build’s assets around?
At least as long as the longest plausible time a client can be holding old HTML: the HTML’s browser TTL, plus the CDN’s HTML edge TTL, plus the tail of long-lived single-page-app sessions that never reload. Fifteen minutes covers the caching layers; 24 hours to seven days covers real user sessions. In a multi-provider setup add the DNS TTL on top, because a failover mid-window can send a client to a provider whose origin has a different retention policy.
Related
- CDN purge strategies — the parent guide covering every provider’s purge model
- Split-brain caches across two providers — diagnosing and reconciling a partial fan-out
- Shield hit ratio versus purge latency — sizing the shield tier with worked arithmetic
- DNS failover and orphaned fingerprinted assets — surviving a cutover mid-deploy
- Fastly instant purge — surrogate keys and sub-second global invalidation