Running a single CDN works until it doesn't. For most teams, the moment arrives without warning: a regional PoP fills during a product launch, a vendor has a bad configuration push, or a contract renewal doubles your per-gigabyte rate. In my experience operating edge delivery for high-traffic media and commerce platforms, a multi-CDN strategy is not about adding redundancy for its own sake. It is about regaining control over three variables your business cannot afford to leave to a single provider: availability during failure, consistent low-latency performance for a global audience, and predictable cost at scale. This article details how I architect multi-CDN deployments that steer traffic intelligently, fail over in seconds rather than minutes, and keep finance, operations, and performance goals aligned.
Why Single-CDN Deployments Fail Under Real-World Load
A single CDN, even a top-tier one, represents a single control plane, a single network footprint, and a single set of operational tradeoffs. I've found that the most dangerous assumption teams make is equating a CDN's global PoP count with guaranteed performance everywhere. PoP density is highly uneven. One provider may dominate in North America and Western Europe but be thin in Southeast Asia, India, or South America. If 30% of your users are in regions where your sole provider is suboptimal, you are systematically delivering a worse experience to a third of your audience.
The second risk is correlation of failure. When a CDN has an edge software bug or a DNS issue, it rarely affects one PoP. It often affects routing logic globally. I lived through an incident where a provider's TLS certificate automation failed, and valid certificates were not served for nearly 40 minutes. No amount of origin redundancy would have helped because the edge itself could not establish connections. Customers on a multi-CDN setup with rapid steering were back to normal in under 60 seconds.
The hidden fragility of regional PoP saturation
Even without a hard outage, a single CDN can degrade. During large live events, I have seen PoP CPU and cache storage saturate, leading to increased origin fetch, higher TTFB, and rebuffering. A single provider has no incentive to tell you its PoP in Jakarta is hot. Your synthetic monitoring from five global nodes may still show green while real users in that region suffer. This is why any serious multi-CDN design starts with Real User Monitoring (RUM) rather than synthetic checks alone.
When vendor lock-in becomes a cost problem
Bandwidth commits are the other forcing function. Most enterprise CDN contracts are built around monthly or annual commits with steep overage penalties. If your traffic grows 40% in a quarter, you either renegotiate mid-term from a weak position or pay overages. With a second or third CDN, you have leverage and a pressure valve for overflow. I've used a secondary CDN with a lower commit and pay-as-you-go pricing to absorb burst traffic, which kept the primary commit utilization between 85-95% — the sweet spot for negotiating renewals.
Architecting Authoritative DNS Failover With Health-Aware Steering
In most multi-CDN implementations, authoritative DNS is the switchboard. It is the only layer that can make a global routing decision before a connection is ever established to an edge. But DNS failover done poorly is slower and more dangerous than no failover at all. Understanding the interaction between Anycast DNS Architecture: How CDNs Route Users to the Nearest Edge and your own authoritative layer is critical — your DNS provider's anycast network determines how quickly clients learn about a change, while your routing logic determines what that change should be.
I strongly advise against relying solely on low TTLs to paper over bad detection. A 10-second TTL does not help if your health check takes 90 seconds to declare a CDN unhealthy, or if resolvers and middleboxes ignore your TTL anyway.
Active health checking beyond a simple HTTP 200
A naive health check fetches /health from the CDN and looks for a 200. That will miss 80% of real CDN failures. A CDN can return 200 for a small object while failing to serve large video segments, failing TLS handshakes, or returning stale content due to a cache purge backlog. In my current setup, we run three tiers of checks from multiple vantage points:
1. TCP/TLS liveness: Can we complete a handshake to the edge hostname in < 2s? This catches network and certificate issues.
2. Functional fetch: Request a cacheable object with a cache-busting header and validate Content-Length, ETag, and TTFB against an SLO. We validate against origin to detect serving stale or truncated content.
3. Performance threshold: If p95 TTFB from a region exceeds 800ms for 2 consecutive minutes, we consider that region degraded, even if requests are technically succeeding.
Checks run every 10-15 seconds from at least 6 geographic probes. A CDN is marked down regionally if 3+ probes agree, globally if 4+ regions agree. This avoids flapping from a single probe's network issue.
TTL tuning for failover speed versus resolver reality
For apex records steering CDN traffic, I run a 30-second TTL during normal operation and drop to 10 seconds only when an incident is declared. Permanently low TTLs increase query volume and cost, and some ISP resolvers enforce a floor of 30-60 seconds regardless. The HTTP Working Group Specifications on caching behavior are useful background here, but DNS TTL clamping is not standardized — you have to measure your actual audience (IETF RFC 8801 on DNS resolver behavior documents some of these mitigations). I've found that the practical failover time is: detection time (20-30s) + authoritative propagation (5-10s) + resolver cache expiry (30-60s). Expect 60-90 seconds end-to-end, not 10.
# Example: Terraform for Route 53 weighted + health-checked multi-CDN records
resource "aws_route53_health_check" "cdn_a_health" {
fqdn = "health-check.cdn-a.example.com"
port = 443
type = "HTTPS"
resource_path = "/cdn-health?check=segment"
failure_threshold = 3
request_interval = 10
measure_latency = true
regions = ["us-east-1", "eu-west-1", "ap-southeast-1"]
}
resource "aws_route53_record" "cdn_steering" {
zone_id = aws_route53_zone.example.zone_id
name = "assets.example.com"
type = "CNAME"
ttl = 30
weighted_routing_policy { weight = 70 }
set_identifier = "cdn-a-primary"
records = ["assets.cdn-a.net"]
health_check_id = aws_route53_health_check.cdn_a_health.id
}
resource "aws_route53_record" "cdn_b_failover" {
zone_id = aws_route53_zone.example.zone_id
name = "assets.example.com"
type = "CNAME"
ttl = 30
weighted_routing_policy { weight = 30 }
set_identifier = "cdn-b-secondary"
records = ["assets.cdn-b.net"]
}
Performance Routing Based on Real User Measurements
Weighted DNS gets you availability. RUM-based performance routing gets you speed. The idea is simple: measure actual download and TTFB performance from real browsers for each CDN, aggregate by geography, ASN, and network type, and steer the next wave of users to the winner for their cohort. In practice, the implementation details determine whether this helps or hurts.
Do not steer per-request at the DNS layer based on a single user's last measurement. DNS has no per-request context. Instead, aggregate measurements in 60-90 second windows and publish a steering map that your authoritative DNS or edge decision service consumes.
Building a lightweight RUM beacon for CDN comparison
I've had success with a dual-fetch beacon: on 1-2% of page views, the client fetches a 20KB probe object from both CDNs (or sequentially, with a timeout) and reports TTFB, download time, and success. Crucially, report the effective connection type via navigator.connection.effectiveType and the client ASN resolved server-side from IP. A CDN that wins on 4G in Brazil may lose on broadband in Germany.
Keep the beacon itself from polluting your measurements. Serve the probe object with Cache-Control: public, max-age=300, stale-while-revalidate=60 and a unique cache key per CDN so you measure edge performance, not origin. Guidance on modern resource hints from web.dev Articles on preconnect and early hints is worth reviewing to avoid delaying LCP with your beacon logic.
Deterministic versus probabilistic steering
Once you have regional performance scores, you have two choices. Deterministic steering sends 100% of a cohort (e.g., ASNs in India) to the current winner. This is simple but causes thundering herd shifts and cache coldness when you flip. Probabilistic steering sends, for example, 70% to the winner and 30% to the challenger. I prefer probabilistic with a 15% exploration budget. It keeps the secondary CDN's cache warm, provides continuous measurements for both providers, and limits blast radius if your scoring is wrong. For critical pages, see how Global Server Load Balancing: GSLB Strategies for Multi-Region Deployments applies similar weighted logic at the origin layer — the same principles translate directly to CDN selection.
// Edge Worker (Cloudflare / Fastly Compute@Edge pseudo-code) - performance-aware CDN selection
// Map is refreshed every 60s from aggregated RUM: { "IN:airtel": "cdn-b", "BR::4g": "cdn-a" }
const STEERING_MAP = await KV.get("perf-steering-v2", "json");
const FALLBACK_CDN = "https://assets.cdn-a.net";
export function selectCdn(request) {
const country = request.headers.get("cf-ipcountry") || "US";
const asn = request.headers.get("x-client-asn") || "0";
const netHint = request.headers.get("x-net-effective-type") || "";
const keys = [
`${country}:${asn}:${netHint}`,
`${country}:${asn}`,
`${country}::${netHint}`,
country
];
for (const k of keys) {
if (STEERING_MAP[k]) {
// Probabilistic check: respect exploration budget stored in map
const winner = STEERING_MAP[k].winner;
const challenger = STEERING_MAP[k].challenger;
const winnerWeight = STEERING_MAP[k].weight; // e.g., 85
return (Math.random() * 100 < winnerWeight) ? winner : challenger;
}
}
return FALLBACK_CDN;
}
Cost-Aware Traffic Shaping Without Sacrificing Performance
Performance routing alone will happily send you into overage fees. Cost-aware routing adds a second optimization loop: meet your commits first, then optimize for performance within that constraint. I've seen teams save 25-35% on delivery costs simply by enforcing commit discipline through automated steering, rather than manual monthly shifts.
The math is straightforward but often ignored. CDN billing is typically 95th percentile for transit or total GB with regional multipliers. A GB served from a cheap region on CDN A may cost half of the same GB on CDN B, while the inverse is true in another region. You need to model cost per GB per region per CDN.
Modeling commits and 95th percentile headroom
I maintain a daily model that tracks: committed GB or Mbps, delivered MTD, projected EOM based on run-rate, and headroom to 95th percentile. If CDN A is at 60% of commit on the 15th of the month, it is under-utilized — you are paying for capacity you are not using. The steering system should gradually shift weight toward the under-utilized CDN, but with guardrails: never shift more than 10% of traffic per 10 minutes, and never violate a performance SLO. For example, if CDN B is 200ms faster in a region but CDN A has 40% commit headroom, we might split 55/45 favoring A instead of 90/10 favoring B.
Implementing gentle weight adjustments
Steering should be closed-loop and observable. We publish intended weights, actual routed traffic (from DNS logs and CDN access logs), and billed usage to the same Grafana dashboard. Any manual override is a temporary annotation that auto-expires after 2 hours, forcing a review.
| Routing Strategy | Failover Speed | Granularity | Best For | Tradeoff |
|---|---|---|---|---|
| DNS Weighted + Health Checks | 60-90 seconds | Per-region / per-record | Baseline multi-CDN availability | Coarse, TTL-limited, resolver caching |
| RUM-Based Performance Map | 1-3 minutes (aggregation window) | Per country / ASN / network type | Latency-sensitive assets, video | Requires beacon infra, careful sampling |
| Anycast + Edge Steering (Worker) | < 1 second (per-request) | Per-request, cookie-aware | Instant failover, A/B at edge | Tied to one CDN's edge network |
| Client-Side Loader (JS) | Immediate (retry) | Per-client, per-resource | Video players, SDKs | Additional client complexity, CORS |
# Cron job that rebalances weights based on commit utilization and performance
# Weighted average: score = (perf_score * 0.7) + (cost_score * 0.3) with SLO gate
import json, requests
rum_scores = requests.get("https://telemetry.example.com/rum/scores?window=5m").json()
commit_status = requests.get("https://billing.example.com/commit/status").json()
for region, scores in rum_scores.items():
# Normalize perf: lower TTFB = higher score
perf_winner = min(scores, key=lambda k: scores[k]['p75_ttfb'])
# Cost adjustment: boost under-utilized CDN by up to 20%
cost_boost = 0
if commit_status['cdn-a']['utilization'] < 0.85:
cost_boost = 15 if region in commit_status['cdn-a']['cheap_regions'] else 5
# Gate: don't steer to CDN if it breaches SLO in that region
if scores[perf_winner]['error_rate'] > 0.01 or scores[perf_winner]['p95_ttfb'] > 900:
continue
new_weights = compute_weights(scores, cost_boost)
# Apply with rate limit: max 10% shift per interval
publish_to_dns_api(region, clamp_weights(new_weights, max_delta=10))
print(f"{region}: publishing {new_weights}")
Synchronizing Cache, Purge, and Configuration Across Heterogeneous CDNs
Multi-CDN magnifies operational toil. Two CDNs means two caching behaviors, two purge APIs, two sets of edge logic, and two ways to get cache keys wrong. If you do not normalize these, you will hit the worst kind of incident: a purge succeeds on CDN A but lingers for 20 minutes on CDN B, serving stale prices or old JavaScript to a slice of users.
In my experience, the most common root cause is inconsistent Vary handling and cache key configuration. One CDN may by default include Accept-Language or Accept-Encoding in the key, another may not. You must explicitly define the key.
Normalizing cache keys and origin shielding
We define a canonical cache key schema and enforce it via Terraform and edge configuration on both providers: host + path + query_allowlist + normalized Accept-Encoding + device class (mobile/desktop) if needed. Everything else is stripped. We also use origin shielding on both CDNs but point shields to different origin regions (e.g., CDN A shield in us-east-1, CDN B shield in eu-west-1) to avoid a single shield region becoming a chokepoint. Paired with CDN Failover and Origin Redundancy: Ensuring Availability During Outages, this gives us both edge and origin resilience.
Atomic purge fan-out with verification
Purges must be fanned out in parallel with verification. I treat purge like a distributed transaction: call both CDNs, poll their purge status endpoints or re-fetch with cache-bust to verify, and alert if they diverge. Purge latency varies wildly — one provider may purge globally in 2 seconds, another in 15-40 seconds. Your application should account for this window. For deployments, we use versioned asset URLs (app.a1b2c3.js) to avoid purges entirely, reserving purges for dynamic content like APIs and HTML.
#!/usr/bin/env python3
# purge_fanout.py - parallel purge with verification
import asyncio, httpx, time
CDNS = {
"cdn-a": {"purge_url": "https://api.cdn-a.net/purge", "token": "TOKEN_A"},
"cdn-b": {"purge_url": "https://api.cdn-b.net/v2/purge", "token": "TOKEN_B"},
}
PURGE_PATHS = ["/api/products/*", "/assets/app.*.js"]
async def purge_cdn(name, cfg, paths):
async with httpx.AsyncClient() as client:
start = time.time()
r = await client.post(cfg["purge_url"],
headers={"Authorization": f"Bearer {cfg['token']}"},
json={"paths": paths}, timeout=10)
r.raise_for_status()
# Poll verification - fetch via each CDN with no-cache
for _ in range(5):
await asyncio.sleep(2)
v = await client.get(f"https://assets.cdn-{name[-1]}.example.net/api/products/123",
headers={"Cache-Control": "no-cache"}, timeout=5)
if v.headers.get("x-cache") == "MISS":
return (name, time.time() - start, "verified")
return (name, time.time() - start, "unverified - alert")
async def main():
results = await asyncio.gather(*[purge_cdn(n,c,PURGE_PATHS) for n,c in CDNS.items()])
for name, elapsed, status in results:
print(f"{name}: {status} in {elapsed:.1f}s")
if "unverified" in status:
# trigger PagerDuty / Slack
pass
asyncio.run(main())
Observability and Incident Response When You Own the Switch
The moment you put a DNS switch in front of two CDNs, you own the most critical component. If your steering logic is opaque, you will be blamed for every incident regardless of which CDN is at fault. Invest heavily in observability before you need it.
We unify logs from both CDNs into a single ClickHouse cluster with a common schema: timestamp, PoP, cache status, status code, TTFB, ASN, country, CDN name, and most importantly, the steering decision reason (e.g., reason=rum_perf_winner or reason=health_failover). This lets us answer in seconds whether an error spike is CDN-specific or global. Also normalize status codes: Fastly's 503 with X-Cache: ERROR and CloudFront's 502 from origin may look different but mean the same thing — origin unreachable.
Runbooks for manual steering
Automation is essential, but you need a one-command manual override. Our runbook has three commands: steer --cdn cdn-b --region global --weight 100 --reason "cdn-a incident INC-1234" --ttl 2h, steer --freeze to pause automated rebalancing, and steer --resume. Every override is logged, requires a ticket reference, and auto-reverts. We drill this quarterly with a game day where we intentionally degrade one CDN in staging and verify detection, automatic shift, and manual override all work.
One final lesson: keep your DNS provider independent of your CDNs. If your DNS is hosted by the same company as your primary CDN and that company has an outage, you lose both delivery and the ability to steer away. Use a dedicated DNS provider with a strong anycast footprint and API that supports health checks independent of any CDN. That separation has saved us twice.
When implemented thoughtfully, multi-CDN ceases to feel like operational overhead and becomes a competitive advantage. You get faster performance for the long tail of users, resilience against vendor failures, and negotiating leverage that directly reduces unit economics. The complexity is real, but it is manageable if you treat the steering layer as a product — with SLIs, tests, and a clear ownership model — rather than a one-off DNS trick.
Frequently Asked Questions
How many CDNs do I actually need for a robust multi-CDN setup?
For most teams, two is sufficient. Two well-chosen CDNs with complementary footprints give you 99% of the availability and performance benefit. A third CDN adds meaningful coverage only if you have significant traffic in regions where both primaries are weak, or if you need leverage for very large volume negotiations. In my experience, the operational cost jumps sharply beyond two — every additional CDN multiplies cache, purge, and configuration work — so start with two and add a third only with data showing a gap.
Does multi-CDN hurt cache hit ratio?
It can, if you split traffic naively. Splitting 50/50 effectively halves the cacheable working set per CDN, which lowers hit ratio for long-tail objects. Mitigate this with three tactics: keep probabilistic steering weighted (70/30 or 80/20) rather than even, use origin shielding to collapse misses, and ensure cache keys are normalized so the same object isn't cached multiple times per CDN. For highly cacheable static assets with versioned URLs, the hit ratio impact is minimal and often offset by better regional performance.
Should I use DNS steering, edge workers, or client-side switching?
Use DNS steering as the foundation for global failover and performance routing — it works for all request types without client changes. Layer edge workers for per-request decisions when you need sub-second failover for HTML or API calls. Reserve client-side switching for video players or SDKs where the client can retry intelligently across CDNs. I typically combine DNS for baseline routing with a thin client loader that retries a failed segment on the alternate CDN; this gives fast DNS-level shifts plus instant client recovery.
How do I avoid inconsistent content between CDNs during a purge?
Use versioned URLs for immutable assets so you rarely need to purge them. For mutable content, fan out purges in parallel to all CDNs and verify with a cache-busting fetch that returns a MISS before declaring success. Build a 30-45 second grace window into your deployment — publish new content to origin, wait for verification, then switch references. Monitor purge lag as a metric and alert if any CDN diverges beyond your SLO.