MODRACXKENNETH D'SILVA

← Archive & Insights

Multi-CDN Architecture Strategies for High Availability

Fastly went down for 49 minutes in June 2021 and a client lost roughly GBP 34,000. We built them a two-provider steering layer. Eighteen months later we turned most of it off, and here is why.

By Kenneth D'SilvaReading Time: 27 min readCategory: Architecture & Cloud

1. The Tuesday Fastly Went Down

On 8 June 2021 a single customer of Fastly pushed a configuration change that triggered a latent bug, and a large fraction of the web returned 503 for about 49 minutes. Reddit, the Guardian, the UK government's own site, Amazon in places. I had two clients affected. One was an art materials retailer doing roughly £40,000 an hour that morning.

Their reaction, once the site came back, was entirely predictable and entirely reasonable: never again, add a second CDN. We spent six weeks building it. It worked. Eighteen months later I helped them turn most of it off.

That is the arc I want to describe, because the multi-CDN conversation is dominated by vendors who sell steering layers and by engineers who have read about Netflix's architecture. Both are talking about a problem that most storefronts do not have. The architecture is real, it works, and I have built it more than once. It is also expensive in ways that do not appear on the invoice, and the honest answer for the large majority of merchants reading this is that you should run one CDN properly instead. If you have not yet got a single CDN caching your HTML, that is where your next six weeks belong, not here.

What follows is how to do it if you have decided to. And a section, near the end, arguing that you probably have not decided — you have reacted.

2. What Actually Fails

Before choosing an architecture, be precise about the failure you are buying insurance against. "The CDN goes down" is four different events with four different mitigations, and multi-CDN only addresses two of them.

Global control-plane failure. The Fastly incident, the Cloudflare outage of 21 June 2022, the Akamai DNS incident of 22 July 2021. A configuration or software fault that takes out a large share of the provider's network more or less simultaneously. Rare — genuinely a small number of events per provider per decade — and total while it lasts. This is the one multi-CDN is built for.

Regional degradation. A PoP or a region goes bad. Slow, not dead: elevated latency, elevated error rates, in one country. Far more common than total failure and much harder to detect, because your monitoring is probably not in that country and your dashboards average it away. Multi-CDN with per-region steering handles this well, and it is arguably the better argument for the architecture than outages are.

Your origin falls over. Multi-CDN does nothing for you. Two edges both fetching from one dead origin means two edges serving errors. What helps here is stale-if-error and origin redundancy, both of which are cheaper than a second CDN by an order of magnitude.

Your own configuration change breaks the site. Statistically the most likely cause of your next edge-related incident, by a distance. A second CDN does not help; if anything it doubles the surface area, because now a bad change can be deployed to two places and be inconsistent between them.

Write those four down and mark which ones you have actually experienced. In my experience the tally is heavily weighted towards the last two, and the architecture people build addresses the first.

3. The Four Reasons People Give

In roughly the order I hear them, with my honest assessment of each.

Availability

The stated reason, nearly always. The maths is seductive: if provider A is available 99.99% of the time and provider B independently is too, the combined availability is 99.999999%.

That maths is wrong, and it is wrong in a specific way worth understanding. The failures are not independent, and your steering layer is a new single point of failure. If you steer by DNS, your DNS provider's availability is now a ceiling on your total availability. If your DNS provider is also one of your CDNs — which it very often is, because both Cloudflare and Akamai sell you both — you have built a correlated dependency and drawn a diagram that hides it.

The realistic gain is something like moving from "one 45-minute outage every three years" to "one 6-minute degradation every three years". That is a real improvement. Price it against what it costs.

Performance

The idea is that no provider is fastest everywhere, so route each region to whichever is fastest there. This is true. Cloudflare has excellent coverage in Europe and North America; Akamai has depth in places others do not reach; some regional providers in China, India and Brazil beat everyone locally.

The size of the effect is usually smaller than the pitch suggests. Between two tier-one providers, in a well-served market, the p75 difference is often 10–30ms. In an underserved market it can be 200ms or more, and that is where the argument holds up. If 30% of your revenue comes from a region where your incumbent CDN is genuinely weak, this is a real business case and I would take it seriously.

Note also that you lose some of the gain to cache fragmentation, which I will come back to. A second provider that is 20ms faster but has half the hit rate is slower.

Cost

Two providers means competitive tension at renewal, and you can shift volume to whoever is cheaper for a given region. At genuine scale — petabytes of egress — this is a serious lever and the savings dwarf the engineering cost.

Below that scale it inverts. Two contracts means two commit minimums, and you will fail to hit both. I have seen a store paying two $2,000 monthly commitments to serve traffic that would have cost $1,400 on one provider. The negotiating position was worth less than the duplicated floor.

Coverage

The least-discussed and most legitimate reason. If you sell into mainland China, you need an ICP licence and a provider with in-country PoPs, and that is simply a different provider from the one serving your European traffic. Same for some Middle Eastern and African markets. This is not multi-CDN as a resilience strategy, it is multi-CDN as a consequence of geography, and it is the one case where I have never argued a client out of it.

4. What Multi-CDN Is Not

Two clarifications, because both come up in every kickoff meeting.

It is not the same as having a CDN and an origin failover. Origin redundancy protects against your application dying. Multi-CDN protects against the delivery layer dying. They are orthogonal and you need both, and if you only have budget for one, do origin redundancy first — your origin fails more often than your CDN does.

And it is not a way to avoid choosing. Running two providers because you cannot decide between them means maintaining two configurations and understanding neither. The teams that run multi-CDN well have a clear primary and a clear secondary, with the secondary held to a deliberately simpler configuration.

5. Steering: The Only Real Decision

Everything else in a multi-CDN build is plumbing. Steering is the architecture, and there are three ways to do it, with a fourth that is really a variant.

DNS steeringAnycast / BGPClient-side
Failover speedTTL-bound, 30–120sSecondsPer request
GranularityResolver, not userNetwork pathIndividual user
Works for HTMLYesYesNo
Operational costModerateHighLow
Who runs itManaged DNS vendorYou, with an ASNYour own JS
Realistic for a storeYesNoPartially

6. DNS Steering, In Detail

The workhorse. Your zone is hosted by a managed DNS provider that supports health checks and weighted or geographic answers — NS1, Cedexis, Constellix, Route 53 with health checks. A resolver asks for www.example.com, and the DNS provider decides which CDN's CNAME to hand back based on where the query came from, current health, and whatever weights you have set.

# Conceptually, the zone looks like this. The apex is a steering
# record rather than a fixed CNAME.

www.example.com.   30   IN   CNAME   <steered>
                                       ├── example.map.fastly.net.
                                       └── example.cdn.cloudflare.net.

# Health checks poll a known object on each provider.
# Weights shift traffic; a failed check drops a provider from the pool.

The configuration in practice, using Route 53 as the example because most people have access to it:

{
  "Comment": "Weighted failover between two CDNs, health-checked",
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "www.example.com",
        "Type": "CNAME",
        "SetIdentifier": "primary-fastly",
        "Weight": 80,
        "TTL": 30,
        "HealthCheckId": "a1b2c3d4-fastly-probe",
        "ResourceRecords": [{ "Value": "example.map.fastly.net" }]
      }
    },
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "www.example.com",
        "Type": "CNAME",
        "SetIdentifier": "secondary-cloudflare",
        "Weight": 20,
        "TTL": 30,
        "HealthCheckId": "e5f6g7h8-cloudflare-probe",
        "ResourceRecords": [{ "Value": "example.cdn.cloudflare.net" }]
      }
    }
  ]
}

Note the 80/20 split rather than 100/0. This is deliberate and it is the single most important detail in the whole design. A secondary that carries no traffic is a secondary you have never tested, with a cold cache, whose configuration has silently drifted from the primary's over six months. When you fail over to it, you discover all of that at once, during an incident, while your origin absorbs a 100% miss rate.

Keep 10–20% on the secondary permanently. It costs you a little hit rate and it is the difference between a failover and a second outage.

Where DNS steering leaks

Three things reduce its precision, and you should know about all three before you promise anyone a failover time.

You are steering resolvers, not users. The DNS provider sees the resolver's address, not the client's. A customer on 8.8.8.8 appears to be wherever Google's nearest resolver is. EDNS Client Subnet fixes this by passing a truncated client prefix along, and support is good among the major public resolvers and patchy elsewhere. Assume your geographic precision is "country, usually" rather than "city".

TTLs are advisory. You set 30 seconds. Some resolvers enforce a floor — 60 or even 300 seconds is not unusual on ISP resolvers and corporate networks. Java applications famously cached DNS forever by default for years. Your real failover time is your TTL plus the worst-behaved resolver in your traffic, and you cannot measure the latter.

Browsers cache too. Chrome keeps its own DNS cache with its own logic, and a page already open with an established connection keeps using that connection regardless of what DNS now says. A customer mid-session on the failed provider stays on the failed provider until their connection drops.

Realistic expectation: most traffic moves in 60–120 seconds, a tail moves in five minutes, and a small tail is still arriving at the dead provider fifteen minutes later. That is a good outcome. Anyone promising sub-ten-second DNS failover is describing a lab.

7. Anycast, And Why You Are Not Doing This

The other approach is to own the address space. You get an ASN, you get a /24, you announce it from multiple providers' networks, and BGP routes each client to the topologically nearest announcement. Withdraw an announcement and traffic reroutes in seconds without DNS being involved at all.

This is how the CDNs themselves work internally, and it is genuinely the best failover mechanism available. It is also a networking project, not a web project. You need an ASN from your regional registry, you need address space, you need providers willing to announce it for you — which is a specific and not universally available product, usually called bring-your-own-IP — and you need someone on staff who understands BGP well enough to debug a route leak at 3am.

I have worked on exactly one storefront where this was the right call, and it was a marketplace with its own network engineering team and regulatory requirements about address ownership. For everyone else, the honest position is that anycast steering is what your CDN sells you, not what you build.

There is a lighter version worth knowing about: some providers will let you point a single anycast address at their network and configure origin failover to a competitor's edge as your "origin". This gives you edge-level redundancy for origin fetch without a steering layer, but it does not survive the primary provider's control plane failing, which is the scenario you were worried about. It solves a different problem than the one that motivated the project.

8. Client-Side Steering

The cheap approach, and more useful than its reputation.

Your HTML is served by the primary. A small script measures both providers on a sample of page loads and rewrites asset URLs — images, scripts, video — to whichever is performing better for that specific user.

// Sampled client-side probe. Runs on 2% of loads, at idle, and
// stores a verdict that later loads read from.
const PROVIDERS = {
  fastly:     'https://a.cdn.example.com/probe.gif?',
  cloudflare: 'https://b.cdn.example.com/probe.gif?',
};

async function timeProvider(url) {
  const started = performance.now();
  try {
    // cache: 'no-store' so we measure the network, not the disk.
    await fetch(url + Date.now(), { cache: 'no-store', mode: 'no-cors' });
    return performance.now() - started;
  } catch {
    return Infinity;
  }
}

async function probe() {
  const names = Object.keys(PROVIDERS);
  const times = await Promise.all(names.map(n => timeProvider(PROVIDERS[n])));
  const best = names[times.indexOf(Math.min(...times))];

  // Store the verdict with an expiry. A single probe is noisy;
  // treat it as a weak signal that decays.
  localStorage.setItem('cdn_pick', JSON.stringify({
    provider: best,
    at: Date.now(),
    ttlMs: 6 * 60 * 60 * 1000,
  }));

  // Report it, because the aggregate of these is the most honest
  // regional performance data you will ever get.
  navigator.sendBeacon('/rum/cdn', JSON.stringify({ times, best }));
}

if (Math.random() < 0.02) {
  requestIdleCallback ? requestIdleCallback(probe, { timeout: 5000 })
                      : setTimeout(probe, 3000);
}

The obvious limitation is that it cannot help with the HTML document, because by the time your script runs the document has already been fetched. So it is not an availability mechanism for the thing that matters most.

What it is genuinely good for is data. Run the probe, collect the beacons, and you have real per-region latency comparisons from real customers on real networks — which is the input you need to decide whether a second CDN is worth having at all. I have used exactly this to talk two clients out of the project, because the data showed a 12ms difference in the markets that mattered.

Build the probe first. Decide second.

9. Health Checks That Detect Real Failure

The failure mode of a naive health check is that it stays green through an outage. During the Fastly incident, the edges were up and answering; they were answering with 503. A check that tests for a TCP connection or a 200 on / from a datacentre with a warm route may well have passed.

A health check needs four properties.

It must exercise the real path. Fetch an actual object through the actual CDN configuration, not a synthetic endpoint the CDN answers specially. I use a small versioned file that goes through the same cache rules as real content.

It must validate content, not status. Check that the body contains an expected string. A 200 with an error page in it is a failure, and this happens more than you would think when an edge serves a stale custom error document.

It must run from multiple regions and require agreement. One probe reporting failure is a probe problem. Three of five reporting failure is an outage. Requiring quorum is what stops you failing away from a healthy provider because one probe location had a bad minute — which is itself an outage you caused.

It must have hysteresis. Fail fast, recover slow. Two consecutive failures to mark down; ten consecutive successes to mark up. Without this, a flapping provider oscillates your traffic, and each oscillation dumps a cold-cache load onto your origin. The flapping is worse than the outage.

#!/usr/bin/env python3
"""Multi-region CDN health probe with quorum and hysteresis.
Runs on a schedule; writes a verdict the DNS layer reads."""

import time, requests
from collections import deque

PROBES = {
    "fastly":     "https://a.cdn.example.com/_health/v3.txt",
    "cloudflare": "https://b.cdn.example.com/_health/v3.txt",
}
EXPECT   = "healthy-v3"      # content check, not just a 200
QUORUM   = 3                 # of 5 regional runners
DOWN_AT  = 2                 # consecutive bad rounds to mark down
UP_AT    = 10                # consecutive good rounds to mark up

history = {name: deque(maxlen=UP_AT) for name in PROBES}
state   = {name: "up" for name in PROBES}

def check(url):
    try:
        r = requests.get(url, timeout=3,
                         headers={"Cache-Control": "no-cache"})
        # A 200 whose body is an error page is a failure.
        return r.status_code == 200 and EXPECT in r.text
    except requests.RequestException:
        return False

def round_once():
    for name, url in PROBES.items():
        ok = check(url)
        history[name].append(ok)
        recent = list(history[name])

        if state[name] == "up" and len(recent) >= DOWN_AT \
           and not any(recent[-DOWN_AT:]):
            state[name] = "down"
            demote(name)
        elif state[name] == "down" and len(recent) == UP_AT \
             and all(recent):
            state[name] = "up"
            promote(name)

def demote(name):
    """Shift weight away. Do NOT drop to zero in one step — a
    partial shift lets you observe whether the origin copes."""
    print(f"DEMOTE {name} at {time.time()}")

def promote(name):
    print(f"PROMOTE {name} at {time.time()}")

The comment on demote is the part I would emphasise. Moving 100% of traffic to your secondary in one step means that provider's cache, which has been carrying 20% of your traffic, now faces 100% — and every miss goes to your origin. Shifting in two or three steps over a couple of minutes lets the secondary warm as it takes load, and lets you abort if the origin starts struggling.

10. Cache Fragmentation: The Cost Nobody Models

Here is the trade-off that the vendor deck omits, and it is the main reason multi-CDN can make a site slower.

Two providers means two independent caches. A URL requested once through provider A is not in provider B's cache. Split traffic 50/50 and, in the crudest model, every object needs fetching twice from origin and each provider sees half the request volume with which to build its cache.

For your top hundred URLs this barely matters — they are requested constantly and both caches stay hot. For the long tail it matters enormously, and on an ecommerce catalogue the long tail is most of it. A product page viewed eleven times a day, split across two providers with a one-hour TTL, may well be a miss on nearly every view.

Concretely: I measured a fashion client's hit rate before and after moving to a 50/50 split. Product page HTML hit rate went from 79% to 61%. Origin requests per minute went up by about 70%. Median TTFB was unchanged, because the hits were still fast, but p90 TTFB got noticeably worse, because there were more misses.

Three mitigations, all partial.

Do not split evenly. An 85/15 split keeps the primary's cache nearly as warm as a single-CDN setup while keeping the secondary exercised. The secondary's hit rate will be poor and that is acceptable — it is a standby, not a peer.

Split by geography, not by weight. Send all of Europe to provider A and all of APAC to provider B. Each provider gets the full request volume for its region, so each cache is as warm as it would be alone. This is much better for hit rate and it does mean that during a regional failover the traffic lands on a genuinely cold cache. Whether that is a good trade depends on how much your origin can absorb.

Shield both providers behind one origin cache. Put Varnish or an origin-side full-page cache between both CDNs and your application. Now a miss on provider B is often a hit at the shield, and your application sees roughly the load it would have seen with one CDN. This is the mitigation I would build first, and it is useful whether or not you go multi-CDN.

11. Cache Warming Across Providers

If you are going to fail over, the secondary's cache should not be empty when you do. Warming is how you handle that, and it is more work than it sounds.

The naive approach is to crawl your sitemap through each provider's edge on a schedule. That works, and the scale is the problem: a 50,000-URL catalogue warmed hourly is 100,000 requests an hour across two providers, most of which are cache hits doing nothing useful.

Warm the head of the distribution, not the whole thing. Pull your top URLs from analytics, weight by revenue rather than pageviews, and warm those.

#!/usr/bin/env bash
# Warm the top N URLs on every provider edge.
# Requests go directly to each provider's hostname with a Host header
# override, so we warm a specific edge rather than whatever DNS picks.
set -euo pipefail

TOP_URLS="/var/lib/warm/top-2000.txt"     # generated nightly from GA
CONCURRENCY=8

declare -A EDGES=(
  [fastly]="a.cdn.example.com"
  [cloudflare]="b.cdn.example.com"
)

warm_one() {
  local edge="$1" path="$2"
  # -H Host so the edge applies the right configuration;
  # --resolve would pin an IP, which we deliberately do not want.
  curl -s -o /dev/null -w '%{http_code} %{time_total}\n' \
       -H "Host: www.example.com" \
       -H "Accept-Encoding: br, gzip" \
       "https://${edge}${path}"
}
export -f warm_one

for name in "${!EDGES[@]}"; do
  echo "warming ${name} (${EDGES[$name]})"
  # xargs -P gives us bounded concurrency without hammering origin
  # if the edge happens to be cold across the board.
  xargs -a "$TOP_URLS" -I{} -P "$CONCURRENCY" \
        bash -c "warm_one '${EDGES[$name]}' '{}'" \
    | awk '{ n++; if ($1 != 200) bad++; t += $2 }
           END { printf "  %d urls, %d non-200, avg %.3fs\n", n, bad, t/n }'
done

Two warnings from having got this wrong. First, warming through a provider that is currently in a bad state generates a flood of origin fetches at exactly the wrong moment; gate the warmer on the health check. Second, warming after a purge-all across two providers doubles the origin load of an operation that was already the most dangerous thing in your runbook. Stagger by provider and rate-limit hard.

12. Purging Two Caches Atomically, Which You Cannot

Invalidation is where a second provider stops being a copy and starts being a distinct system with its own behaviour.

You change a price. You fire a tag purge at both providers. One acknowledges in 150ms and propagates globally in about a second; the other acknowledges immediately and propagates over the next eight. For those eight seconds, some customers see the old price and some see the new one, depending on which provider their resolver happened to hand them. There is no way to make this atomic and no provider offers a two-phase commit for cache invalidation.

Usually this does not matter. It matters a great deal when the thing you are purging is a legal price change, a stock-out, or a promotional banner with a hard start time, and it matters most when the two views are visible to the same person across a page navigation.

What I do about it:

#!/usr/bin/env python3
"""Fan a tag purge out to every provider, in parallel, with retries.
The contract is 'eventually consistent within N seconds', and the
job's real output is the worst-case propagation time so that the
application can reason about its own staleness window."""

import concurrent.futures as cf
import time, requests

PROVIDERS = [
    ("fastly", "https://api.fastly.com/service/{svc}/purge/{tag}",
     {"Fastly-Key": "..."}, "POST"),
    ("cloudflare",
     "https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache",
     {"Authorization": "Bearer ..."}, "POST"),
]

def purge(provider, tags, attempt=1):
    name, url, headers, method = provider
    started = time.time()
    try:
        r = requests.request(method, url.format(tag=tags[0], svc="S", zone="Z"),
                             headers=headers, json={"tags": tags}, timeout=5)
        r.raise_for_status()
        return name, True, time.time() - started
    except requests.RequestException:
        # A failed purge on one provider is worse than a slow one:
        # that provider is now serving content the other has dropped.
        if attempt < 4:
            time.sleep(2 ** attempt)
            return purge(provider, tags, attempt + 1)
        return name, False, time.time() - started

def purge_all(tags):
    with cf.ThreadPoolExecutor(max_workers=len(PROVIDERS)) as pool:
        results = list(pool.map(lambda p: purge(p, tags), PROVIDERS))

    failed = [n for n, ok, _ in results if not ok]
    if failed:
        # Do not swallow this. A provider that missed a purge must be
        # demoted until it can be purged, or it will serve stale
        # content indefinitely.
        raise RuntimeError(f"purge failed on {failed} for tags {tags}")

    return max(d for _, _, d in results)

The exception on partial failure is the important line. The instinct is to log a warning and move on, because the purge mostly worked. But a provider that missed an invalidation is now serving content that everyone agrees is wrong, to 15% of your customers, until its TTL expires. Treat a failed purge as an availability event on that provider and demote it. I learned this from a client whose Cloudflare token had expired unnoticed; product pages served through Cloudflare were up to eleven hours stale for two days before anyone connected the dots.

A related irritation: purge rate limits differ. Fastly's surrogate key purges are generous, Cloudflare's API caps requests per minute, and a large catalogue import that fires four thousand tag purges will breeze through one and get throttled by the other. Batch, queue, and rate-limit to the slowest provider's ceiling rather than discovering it during a Friday import.

13. Configuration Drift Is The Real Enemy

Six weeks after launch, the two providers agree. Six months later they do not, and nobody knows, because the secondary carries 15% of traffic and its problems look like noise.

Drift accumulates from ordinary work. Someone adds a redirect rule on the primary during an incident. A new image path gets a cache rule on the primary only. A security header is added to one WAF configuration. Each change is small and each is deployed to the place where the traffic is.

Two things prevent this and you need both.

Configuration as code, in one repository, with a single source of truth that generates both. Not two Terraform modules maintained in parallel — one abstract description of your caching policy, and two renderers.

# cdn-policy.yaml — the single source of truth.
# A generator emits Fastly VCL and a Cloudflare ruleset from this.
# Nobody edits either provider's console directly; the console is
# read-only in production and that rule is enforced by an audit job.

defaults:
  ttl_edge: 3600
  ttl_browser: 60
  stale_while_revalidate: 86400
  stale_if_error: 604800

cache_key:
  query_allowlist: [page, sort, colour, size]
  cookie_allowlist: [currency, trade]
  normalise_accept_encoding: true

routes:
  - match: "^/checkout"
    cache: never
    headers: { Cache-Control: "private, no-store" }

  - match: "^/customer|^/cart|^/admin"
    cache: never

  - match: "^/products/"
    ttl_edge: 3600
    surrogate_keys_from_origin: true

  - match: "\\.(js|css|woff2)$"
    ttl_edge: 31536000
    ttl_browser: 31536000
    immutable: true

A differ that runs on a schedule and shouts. Fetch the same set of URLs through both providers and compare the response headers that matter. Anything that differs and is not on an allowlist of expected differences is an alert.

#!/usr/bin/env python3
"""Compare responses across providers. Runs hourly in CI.
Catches the drift that a 15% traffic share will never surface."""

import sys, requests

EDGES = {"fastly": "a.cdn.example.com",
         "cloudflare": "b.cdn.example.com"}

PATHS = ["/", "/products/oak-dining-table", "/collections/dining",
         "/checkout", "/robots.txt", "/assets/app.css"]

# Headers that legitimately differ between providers.
IGNORE = {"date", "age", "server", "cf-ray", "x-served-by",
          "x-cache", "x-timer", "cf-cache-status", "report-to",
          "alt-svc", "connection", "nel"}

def fetch(edge, path):
    r = requests.get(f"https://{edge}{path}",
                     headers={"Host": "www.example.com"},
                     timeout=10, allow_redirects=False)
    return r.status_code, {k.lower(): v for k, v in r.headers.items()
                           if k.lower() not in IGNORE}

failures = 0
for path in PATHS:
    results = {name: fetch(host, path) for name, host in EDGES.items()}
    names = list(results)
    base_status, base_headers = results[names[0]]

    for other in names[1:]:
        status, headers = results[other]
        if status != base_status:
            print(f"DRIFT {path}: {names[0]}={base_status} "
                  f"{other}={status}")
            failures += 1
        for key in set(base_headers) | set(headers):
            a, b = base_headers.get(key), headers.get(key)
            if a != b:
                print(f"DRIFT {path} [{key}]: {names[0]}={a!r} "
                      f"{other}={b!r}")
                failures += 1

sys.exit(1 if failures else 0)

That differ has caught more real problems for me than the health checks ever have. On one build it found that a security header added during a PCI review nine months earlier existed on the primary only, which meant a failover would have quietly dropped it — and the PCI evidence pack said otherwise.

14. Knowing Which Provider Served What

The day after you go live, someone reports a bug. A stale price, a broken image, a redirect loop. Your first question is which provider served that request, and if you cannot answer it in under a minute you will spend the next year in a fog.

Instrument this before you need it. Every response should carry a header naming the provider and the PoP, normalised into one field rather than the six different vendor-specific headers you would otherwise be reading. Both providers can inject a synthetic header at the edge; the names differ, the technique does not.

// Collected in RUM alongside the timings, so that every performance
// number in your dashboard can be sliced by provider. Without this,
// a regression on one provider averages away into noise.
addEventListener('load', () => {
  const nav = performance.getEntriesByType('navigation')[0];
  if (!nav) return;

  // Server-Timing is the only response header the browser exposes to
  // JavaScript, so both edges are configured to emit into it:
  //   Server-Timing: cdn;desc="fastly", pop;desc="LHR", cache;desc="HIT"
  const st = Object.fromEntries(
    (nav.serverTiming || []).map(t => [t.name, t.description])
  );

  navigator.sendBeacon('/rum/nav', JSON.stringify({
    path: location.pathname,
    ttfb: Math.round(nav.responseStart),
    provider: st.cdn || 'unknown',
    pop: st.pop || '',
    cache: st.cache || '',
  }));
});

Then build exactly one dashboard: p75 TTFB by provider by country, with the request share alongside. Everything else you will want can be derived from that table. The share column matters because a provider carrying 3% of traffic will show wild percentile swings that mean nothing, and you need to see the denominator to avoid chasing them.

The other piece is log correlation. Both providers can add a request ID; make sure the same ID reaches your origin logs so that a slow request at the edge can be traced to the origin fetch that caused it. Doing this after the fact, during an incident, across two log schemas, is miserable — I have done it and I would rather not again.

15. Regional Steering In Practice

Weighted global steering is simple and it wastes the main performance argument for multi-CDN. If provider B is genuinely better in Brazil and provider A is better in Germany, a global 85/15 split gives 15% of German traffic to the slower provider and 85% of Brazilian traffic to the slower one. You have paid for two networks and used neither well.

Regional steering fixes that, and it introduces a problem people do not anticipate: each region now has one provider with a warm cache and one with a cold one. A failover within a region is a failover to an empty cache. Global weighting keeps both caches lukewarm everywhere; regional steering makes each cache hot in its own region and cold outside it.

The compromise I have settled on is regional primaries with a small global bleed. Each region routes 90% to its best provider and 10% to the other, so that no cache is entirely cold anywhere. Something like this:

# steering.yaml — consumed by the DNS provider's API.
# Weights are per-region, and no region sends less than 10% to its
# secondary, because a cache at 0% share is a cache that does not exist.
regions:
  - match: [GB, IE, DE, FR, NL, ES, IT]
    weights: { fastly: 90, cloudflare: 10 }

  - match: [US, CA, MX]
    weights: { fastly: 85, cloudflare: 15 }

  - match: [AU, NZ, SG, JP]
    # Measured 60ms better here across three months of RUM,
    # which is the only reason this is inverted.
    weights: { cloudflare: 90, fastly: 10 }

  - match: ["*"]
    weights: { fastly: 50, cloudflare: 50 }

failover:
  # When a provider is demoted, its weight goes to the other in
  # two steps 90 seconds apart rather than one, so the surviving
  # provider's cache and your origin get a chance to absorb it.
  steps: [50, 100]
  step_interval_seconds: 90

Set those weights from your own measurements, not from a vendor's coverage map. Coverage maps show PoP locations, which tells you almost nothing about the path your customers' networks actually take to reach them. The client-side probe data described above is the input that matters, and it will occasionally tell you something surprising — on one build the provider with fewer PoPs in Australia was consistently faster there, because of peering with the dominant local ISP.

16. The Boring Sharp Edges

Things that are not architecture but will consume your first month.

Certificates. Both providers need a valid certificate for your hostname. Managed certificate issuance on both, with two ACME clients competing for the same domain validation, produces rate-limit failures and occasional issuance loops. Either bring your own certificate to both, or make sure only one provider is doing ACME and the other is loading the same bundle.

Origin authentication. If you restrict origin access by IP allowlist, you now need both providers' ranges, and those ranges change. Move to a shared secret header validated at origin instead — a header your CDNs inject and your origin requires. It survives IP changes and it works identically for both.

Logs. Two providers, two log formats, two delivery mechanisms. Normalise them into one store before you need them, not during the incident where you need them.

WAF rules. The two providers' rule engines are not equivalent and a rule that blocks an attack on one will not exist on the other. This is the most dangerous drift category, because the failure is silent and the consequence is a breach rather than a slow page.

Bot management, image optimisation, and every value-added feature. These are precisely the things that differ between providers, and if you rely on one provider's automatic image resizing, your failover serves unoptimised images. Either use only the intersection of what both providers offer, or accept that failover means degraded service. I prefer the intersection, and it means giving up features you are paying for, which is a real cost.

17. What It Actually Costs

The invoice is the smallest part.

Two contracts. Two commits, and you will overshoot neither. Budget for paying roughly 1.3× your single-provider bill for the same traffic at moderate scale.

A steering layer. Managed DNS with health checks and traffic steering is a real product with a real price. Under $500 a month at small scale, well into four figures with query volume.

Higher origin load. From fragmentation. On the fashion client this meant an extra pair of application servers, which cost more per year than the secondary CDN did.

Engineering time. Six to ten weeks to build properly. Then, and this is the part that gets underestimated by a mile, ongoing: every edge change is now two edge changes plus a verification. I would put it at a day a month, forever, plus a bad day whenever something drifts.

Cognitive load. Every debugging session now begins with "which provider served this request". Every customer report needs a provider attached before it means anything. This is not billable and it is the cost engineers feel most.

Set that against the loss you are insuring. If a 45-minute outage costs you £30,000 and you expect one every three years, the expected annual loss is £10,000. If multi-CDN costs you £4,000 a month all-in, you are paying £48,000 a year to avoid an expected £10,000 loss, and you are adding new failure modes in the process. The maths only works at a revenue scale where the outage number is much larger.

18. Most Merchants Should Not Do This

I want to be blunt, because the rest of this article is a competent description of how to build something that most readers should not build.

If your store does under roughly £50 million a year, multi-CDN is very probably the wrong investment. Not because it does not work — it works — but because the same engineering effort spent elsewhere returns more.

Here is what I would do with six weeks and the same budget, in order.

Set stale-if-error on everything anonymous. One header. During a partial origin failure or a slow backend, your edge keeps serving. This covers a meaningful share of the incidents that multi-CDN would have covered, at zero cost, in an afternoon.

Fix your origin's single points of failure. Most storefronts have one database primary, one Redis, one search cluster, and no tested restore procedure. Your origin will fail before your CDN does. It is not close.

Get your HTML cache hit rate above 80%. This does more for your p75 TTFB than any steering layer, it reduces origin load, and it means an origin failure degrades rather than destroys.

Write and rehearse a manual failover runbook. Have a second CDN configured, tested quarterly, carrying zero traffic, with a documented DNS change that a named person can make in ten minutes. This is 90% of the availability benefit for 10% of the cost and none of the ongoing drift, because you are not maintaining two live configurations — you are maintaining one live and one rehearsed.

That last one is the recommendation I give most often and it is what the art materials retailer ended up with. Configured secondary, quarterly drill, no live steering. When they tested it in the drill after we simplified, the failover took eleven minutes and the site was slow for twenty. Compared to 49 minutes of hard down, that is a fine outcome for a fraction of the price.

The exception, again: if you sell into a market that your primary provider genuinely cannot serve, you are not doing multi-CDN for resilience and none of the above applies. Build it.

19. A Worked Example

The art materials retailer. UK-headquartered, roughly £95 million annual online revenue, meaningful traffic from the US and Australia, Fastly as the incumbent.

The trigger. The June 2021 outage. 49 minutes, an estimated £34,000 in lost orders, and a board-level question about single points of failure.

What we built. NS1 for steering with EDNS Client Subnet enabled. Fastly primary at 85%, Cloudflare secondary at 15%, weighted globally rather than geographically because their traffic was concentrated enough that per-region splits fragmented the cache badly in testing. Health probes from five regions with quorum of three, two rounds to demote, ten to promote. A YAML policy file generating both configurations. Hourly drift checks. A top-2,000 warmer running every four hours against both edges.

Timeline. Nine weeks, not the six we quoted. The overrun was entirely certificates and WAF rule translation.

What went wrong, once it was live. Three things.

In month two, a probe location in Singapore had a bad twenty minutes and reported Fastly down. Quorum held and nothing shifted, which is the system working. But it generated eleven pages overnight and the on-call engineer, reasonably, muted the alert. Two weeks later a real regional degradation went unnoticed for an hour because the alert was still muted. The system was right and the humans routed around it. We rebuilt the alerting to distinguish "a probe is unhappy" from "quorum reached" and only page on the latter.

In month four, the p90 TTFB regression from cache fragmentation became visible in the RUM data. It had been there since launch and nobody had looked, because the median was fine. We added an origin-side Varnish shield, which recovered most of it, and which we should have built first.

In month seven, a genuine Cloudflare regional issue in APAC triggered a failover. It worked. Australian traffic moved to Fastly in about 90 seconds. Then Fastly's APAC edge, which had been carrying 15% of a small share of total traffic, took the full Australian load with a mostly cold cache, and origin request rate tripled for four minutes. No outage, but the origin was closer to the edge of its capacity than anyone was comfortable with. That is the failure mode nobody models: the failover succeeds and the consequences of success are the problem.

What it delivered. Over eighteen months: one real failover, handled well. Zero total outages. Measurable improvement in Australian p75 TTFB of about 45ms from steering, which was real but smaller than hoped.

Why they simplified. In year two the team shrank. Maintaining two edge configurations required a person who understood both, and that person left. Rather than hire for it, they moved to the configured-standby model: Cloudflare fully configured, carrying no traffic, drilled quarterly, with the drift checker still running so the configuration stays current. They kept the health monitoring and dropped the automated steering.

The honest read. The build was competent and it was over-engineered for their situation. If I had run the client-side probe first and shown them the 12ms regional differences, I think we would have gone straight to the standby model and saved eight weeks. I did not run the probe first because the client had already decided what they wanted and I was happy to build it. That is on me.

20. Questions People Ask

"Can I just use two CDNs with round-robin DNS?" You can and you should not. Round robin has no health awareness, so when one provider fails, roughly half your visitors get errors until you change DNS manually — and their resolvers cache the dead answer. Round robin is load distribution, not failover.

"Should the secondary be a cheaper provider?" Tempting and usually wrong. A secondary you would not accept as a primary means failover is a service degradation you have pre-agreed to. If you are going to run one, run two you would be happy with, and take the cost.

"How do I test failover without an outage?" Scheduled drills, in business hours, announced. Shift 100% to the secondary for an hour and watch your RUM data. If you are not willing to do that, you do not have a tested failover, you have a hope. The first drill will find something; the third one will be boring, which is the goal.

"Does multi-CDN affect SEO?" Not directly, and slightly indirectly in a way worth knowing. Googlebot resolving your hostname gets steered like anyone else, and it will see whichever provider your steering picks for its crawler ranges. If the two providers differ in response headers, redirects, or bot rules, the crawler sees an inconsistent site. The drift checker covers this; make sure /robots.txt and your canonical headers are on its path list.

"What about the steering layer's own reliability?" This is the question people avoid. Your managed DNS provider is now the thing that must never fail. Use one with an anycast network and multiple independent nameserver groups, and consider delegating to two DNS providers with the zone synchronised — which is another whole project, and at that point ask yourself how far down this road you meant to travel.

"Can I run multi-CDN on Shopify?" No, not for the storefront. Shopify serves your storefront through their own infrastructure and you cannot put your own edge in front of the checkout. You can front a headless frontend with your own CDN, and then the multi-CDN question applies to that layer while the Shopify APIs remain a shared dependency you cannot make redundant.

"Is a CDN plus a static fallback site a reasonable poor-man's version?" Yes, and I like it more than it deserves. A static export of your top category and product pages, hosted somewhere completely independent, that DNS can be pointed at manually. It is not a real failover, browsing is degraded and you cannot take orders, but it turns a dead site into a slow catalogue for the price of a nightly build job.

"How many providers is too many?" Two. Three if one is a regional necessity like China. I have seen four proposed and the proposal was a symptom rather than a plan.

21. What I'd Do First

Write down the last three availability incidents you actually had, with causes. If none of them were "the CDN's control plane failed globally", start somewhere else — most likely at your origin.

Ship stale-if-error today, on every anonymous response. It is one header and it covers more incidents than the architecture in this article.

Build the client-side probe and let it run for a month. You need real per-region latency data from your own customers before you can price the performance argument, and that data frequently kills the project.

Get your single CDN's HTML hit rate above 80% and put a shield cache in front of your origin. Both of these make a multi-CDN build easier later and make it less necessary now.

Then, if the case still holds, configure a secondary provider fully and route zero traffic to it. Run the drift checker hourly from day one. Drill it quarterly, in business hours, with the whole team watching.

Only after two clean drills would I put live traffic on the secondary, and I would start at 10% rather than 50%, and I would watch p90 TTFB rather than the median, because the median will not show you the fragmentation.

And keep asking, at every stage, whether the money would do more somewhere else. On most storefronts I have looked at, it would.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: