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.

Multi-CDN topology with a shared shield DNS steers users across two CDN edges. Both edges pull cache misses from one shared shield tier, which pulls from a single asset origin bucket holding fingerprinted files, so both providers serve byte-identical hashed assets. Two providers, one shield tier, one asset origin Users DNS steered CDN A edge 70% of traffic CDN B edge 30% of traffic Shield tier shared by both origin offload Asset origin one bucket 8-hex hashes Only the HTML is mutable — hashed assets never need a purge at any layer
Both providers resolve misses through one shield and one bucket, so a given hash is byte-identical whichever edge answers.

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-level await in ESM. Node 18 works if you replace AbortSignal.timeout() with a manual AbortController timer.
  • 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 Purge on one zone, a Fastly token with purge_all on one service, an IAM role limited to cloudfront:CreateInvalidation on 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.

Purge ordering Left panel shows edges purged first, causing edge misses to re-pull stale HTML from the shield. Right panel shows the shield purged first, so the following edge purge re-pulls the new build. Purge order decides whether the edge re-pulls the old build Wrong order 1. Purge edge PoPs 2. Edge misses, pulls from shield 3. Shield still holds old HTML Stale bytes re-cached at the edge Right order 1. Purge shield tier first 2. Purge both edge networks 3. Edge misses, pulls new HTML New build served everywhere
Purging the shield last leaves a repopulation window that actively re-caches the previous build at both edges.

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.

Purge fan-out timing Horizontal bars showing the elapsed time of each stage of one deploy purge: the shield stage completes first, edge A returns quickly, and edge B needs two attempts after a rate-limit response, setting the total wall-clock time. Wall-clock time for one deploy purge fan-out Shield purge 180 ms Edge A call 96 ms Edge B try 1 230 ms — HTTP 429 Backoff wait 270 ms sleep Edge B try 2 420 ms total — 200 OK The deploy step is only complete when the slowest branch returns 2xx
One rate-limited branch sets the total. Budget the deploy gate against the slowest provider, not the average.

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.

Purge job state machine A purge job moves from pending to in flight. A 2xx response marks it done. A 429 or 5xx sends it to a backoff state and back to in flight, and exhausting the attempt budget moves it to failed, which blocks the deploy. Each provider call is one idempotent, retryable job Pending job queued In flight POST purge Backoff 250 ms doubling Failed deploy blocked Done 2xx recorded 429 or 5xx retry attempts exhausted 2xx
Five states, no sixth. A job that is not Done is a job that blocks the release.

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 Completed rather than accepting InProgress.
  • 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.