DNS Weighted Routing for CDN Traffic Distribution: Proportional Load Splitting

Back in 2018, we were handling a major e-commerce launch that was supposed to split 80% of traffic to our new edge stack and keep 20% on the legacy PoPs. The weighted records looked perfect in the console, but within twenty minutes the origin was melting because resolver caching had skewed the actual split to nearly 50/50. That’s the moment I learned weighted DNS routing isn’t just about setting numbers — it’s about understanding how resolvers, TTLs, and client behavior turn those numbers into real traffic distribution.

How Weighted Records Actually Distribute Traffic

At its core, weighted DNS routing is the simplest form of DNS balancing you can implement. You publish multiple A or AAAA records for the same hostname, each with a numerical weight, and your authoritative name server responds to queries by picking one of those records with a probability proportional to its weight. If you’ve got two CDN endpoints — say, cdn1.example.net at weight 70 and cdn2.example.net at weight 30 — then over a large enough sample size, about 70% of resolver queries should land on the first endpoint. It sounds deterministic, but it’s fundamentally probabilistic.

I’ve seen teams treat this like a layer 4 load balancer, expecting precise, per-request splitting. It doesn’t work that way. Unlike an HTTP-aware proxy that can count connections, DNS has no feedback loop. An authoritative server doesn’t know how many users are behind a single resolver. A large ISP resolver caching your response might represent 10,000 users, while another represents ten. This is why we can’t guarantee 70/30 on a minute-by-minute basis — we’re influencing probability, not directing packets. When you combine weighted routing with something like EDNS Client Subnet for CDN Geolocation: Routing Users to the Nearest Edge, the math gets even more nuanced because the subnet information can cause resolvers to cache different weighted answers for different client subnets.

The terminology also trips folks up. Weighted routing isn’t the same as round-robin. Round-robin just cycles through records in order, giving each one equal share. Weighted routing assigns that proportional value. Most modern managed DNS providers — Route 53, Cloudflare, NS1, Dyn — implement this by normalizing the weights. You don’t have to make them add up to 100. Weights of 7 and 3 behave identically to 70 and 30. Here’s how a typical setup looks if you were configuring it via AWS CLI for two CDN origins handling load splitting:

# Create two weighted A records for cdn.example.com
# Endpoint A gets 70% of DNS queries
aws route53 change-resource-record-sets --hosted-zone-id Z123456789 \
--change-batch '{
  "Changes": [{
    "Action": "CREATE",
    "ResourceRecordSet": {
      "Name": "cdn.example.com",
      "Type": "A",
      "SetIdentifier": "cdn-primary-fra",
      "Weight": 70,
      "TTL": 60,
      "ResourceRecords": [{"Value": "192.0.2.10"}]
    }
  },{
    "Action": "CREATE",
    "ResourceRecordSet": {
      "Name": "cdn.example.com",
      "Type": "A",
      "SetIdentifier": "cdn-secondary-iad",
      "Weight": 30,
      "TTL": 60,
      "ResourceRecords": [{"Value": "198.51.100.20"}]
    }
  }]
}'

# Verify distribution - query repeatedly from different resolvers
for i in {1..20}; do dig @8.8.8.8 cdn.example.com +short; done | sort | uniq -c

TTL, Caching, and Why Your 90/10 Split Became 60/40

The biggest mistake I see with traffic distribution via DNS is setting a TTL that’s way too high for the level of control you want. If you set a 3600 second TTL on a 90/10 weighted split, a resolver that caches the 10% answer will continue to send all of its clients to the smaller pool for an hour. You haven’t really done 90/10 anymore — you’ve frozen a random sample in place. For active load splitting, especially during migrations or canary releases, I don’t go above 60 seconds. Yes, it increases query volume on your authoritative servers, but the cost is negligible compared to an overloaded edge.

There’s another subtlety with DNS NS Delegation for CDN: Offloading Domain Resolution to Edge Networks that impacts this. When you delegate your CDN hostname to the provider’s authoritative network, your weight logic lives on their infrastructure, not yours. That’s usually what you want for performance, but it means you’re dependent on how they implement the weighting algorithm. Some providers don’t do true weighted random selection per query — they use a hash of the client subnet or a sticky decision for a few seconds to improve cache friendliness. It’s worth testing with real digs from diverse networks before you trust the dashboard.

We learned this the hard way during a blue-green CDN migration. We wanted to move traffic gradually: 10%, then 25%, then 50%. Our graphs showed the actual traffic lagging behind the configured weights by about 15 minutes each time. The culprit was intermediate caching at ISP resolvers and also in end-user operating systems and browsers, which honor TTLs per the specifications documented by the HTTP Working Group Specifications and caching best practices covered in web.dev Articles. To get accurate observability, we stopped trusting DNS query counts and started measuring HTTP request counts at the edge. This script is similar to what we now run every time we adjust weights — it tails the edge logs and compares the configured vs. observed split:

#!/usr/bin/env python3
import collections
import time

# Poll edge logs from both CDN endpoints and calculate actual split
# Assumes logs are shipped to a central aggregator with PoP identifier
def calculate_actual_split(log_file="/var/log/cdn/access.log", window_seconds=60):
    counts = collections.Counter()
    cutoff = time.time() - window_seconds
    
    with open(log_file) as f:
        for line in f:
            # log format: timestamp pop_id status_bytes ...
            parts = line.split()
            ts, pop = float(parts[0]), parts[1]
            if ts >= cutoff:
                counts[pop] += 1
    
    total = sum(counts.values())
    if total == 0:
        return {}
    
    for pop, hits in counts.items():
        pct = (hits / total) * 100
        print(f"{pop}: {hits} requests ({pct:.1f}%)")
    
    # Alert if deviation > 5% from configured weight
    expected = {"cdn-primary-fra": 70.0, "cdn-secondary-iad": 30.0}
    for pop, exp in expected.items():
        actual = (counts[pop] / total) * 100 if pop in counts else 0
        if abs(actual - exp) > 5.0:
            print(f"WARN: {pop} deviation {abs(actual-exp):.1f}% exceeds threshold")

calculate_actual_split()

Validating Weighted Splits With Real Edge Metrics

You can’t tune what you don’t measure, and with weighted DNS routing the configured weight is just an intention until traffic proves it out. I always tell teams to set up three layers of validation: DNS-level checks (are we serving the right records at the right frequency?), edge-level checks (are requests actually arriving in the expected ratio?), and client-performance checks (is the smaller pool experiencing higher latency because it’s farther away or under-provisioned?). For CDN work, that last point matters — splitting traffic 50/50 between Frankfurt and Virginia doesn’t mean users get equal performance.

This is also where DNS CNAME Flattening: Pointing Root Domains to CDN Without Breaking DNS Rules can silently affect your results. If you’re using CNAME flattening or ANAME at the apex, your flattening resolver’s cache can coalesce weighted answers into a single IP for longer than your TTL intends. We once saw an apex domain stuck at 100% on one PoP for 10 minutes after a weight change because the flattening layer cached aggressively. Now we isolate weighted logic to a dedicated hostname like weighted.cdn.example.com and CNAME to it, rather than applying weights directly at the apex.

Below is a snapshot from a recent gradual migration we ran for a media customer. We shifted weights in stages and captured both the configured split and the actual HTTP request distribution measured at the edge over a 10-minute window per stage. TTL was fixed at 45 seconds throughout. As you can see, the deviation shrinks as sample size grows, but short TTLs are what kept the actual distribution close to the target.

Stage Configured Weight (PoP-A / PoP-B) Observed HTTP Requests (10-min window) Deviation from Target Avg. TTFB (PoP-A / PoP-B)
Canary 90 / 10 88.4% / 11.6% +1.6% on PoP-B 42ms / 68ms
Ramp 1 75 / 25 73.1% / 26.9% +1.9% on PoP-B 44ms / 61ms
Ramp 2 50 / 50 49.2% / 50.8% +0.8% on PoP-B 45ms / 55ms
Ramp 3 25 / 75 26.3% / 73.7% +1.3% on PoP-A 48ms / 52ms
Full Cutover 0 / 100 0.4% / 99.6%* Residual cache - / 51ms

Putting Weighted Records Into Production Without a Flash Cut

Don’t just flip a 90/10 weight and walk away. If you’re migrating between PoPs or testing a new cache stack, you’ll want a progressive rollout, not a big bang. We typically start with 95/5 or even 98/2 for the first 24 hours, then step to 90/10, 75/25, and finally 50/50. That gives your origin shields, cache warming, and autoscaling time to react. I’ve seen a cold 10% slice spike origin offload by 400% because the new edge had a 0% cache hit rate for the first hour — your weighted split was correct, but your origin wasn’t ready for it.

In Route 53, Cloudflare, or NS1, the math is simple but easy to fat-finger: weight / sum(weights) = share of traffic. So 90 and 10 is 90%, but 90, 10, and 10 is 81.8% / 9% / 9%. Always associate a health check with each weighted record and set a short TTL during migration. We run 60 seconds for the first 48 hours of any weighted shift, then bump to 300 seconds once it’s stable. And if you’re signing your zone, remember that weighted responses still need valid RRSIGs — a misconfigured rotation will make your canary look like it’s failing when it’s actually just bogus. See DNSSEC Signing for CDN Infrastructure: Authenticated DNS Without the Performance Hit before you push weighted records into a DNSSEC-signed zone.

Here’s what a safe Terraform-based 90/10 canary looks like with health checks attached. Notice the explicit set_identifier and the shared FQDN — that’s what makes it weighted, not just two A records:

resource "aws_route53_record" "cdn_primary" {
  zone_id = aws_route53_zone.example.zone_id
  name    = "cdn.example.com"
  type    = "A"
  ttl     = 60

  set_identifier = "cdn-iad-primary"
  weighted_routing_policy {
    weight = 90
  }
  records         = ["192.0.2.10", "192.0.2.11"]
  health_check_id = aws_route53_health_check.iad.id
}

resource "aws_route53_record" "cdn_canary" {
  zone_id = aws_route53_zone.example.zone_id
  name    = "cdn.example.com"
  type    = "A"
  ttl     = 60

  set_identifier = "cdn-lax-canary"
  weighted_routing_policy {
    weight = 10
  }
  records         = ["198.51.100.20"]
  health_check_id = aws_route53_health_check.lax.id
}

# Rollout tip: change weights to 75/25 -> 50/50 -> 0/100
# Keep TTL at 60s until 24h after final cut, then raise to 300s

Advanced Patterns and Troubleshooting When Your Split Drifts

Weights don’t failover on their own — you have to tell them to. Attach health checks that actually test cache viability, not just ICMP. We run HTTP checks against /__health?cache_test=1 every 10 seconds from 8+ regions, with a 3-second timeout and 2 consecutive failures to mark unhealthy. When a target fails, Route 53 and similar providers remove it from the pool and re-normalize the remaining weights. So if your 90/10 becomes 90/0, 100% of resolvers will get the 90-weight answer until it recovers. We also use a 0-weight “dark” record for blue-green deploys — it gets no traffic but stays healthy and signed, so you can flip it to 5 weight in seconds without a zone push.

If your edge metrics show 63/37 when you configured 80/20, don’t immediately blame DNS. Check three things in order: 1) Resolver cache stickiness — large ISP resolvers in Brazil and India will pin a single answer for the full TTL and fan it out to millions of clients. That alone can skew a 10% canary to 16-18%. 2) Client reuse — mobile apps and browsers keep TCP/TLS connections alive for 90-120 seconds, long past your 60-second TTL. 3) Uneven health. We’ve debugged “weight drift” that was actually DNS Sinkholing at CDN Scale: Redirecting Malicious Traffic for Analysis rules or bot traffic being steered differently. And if you lower TTL to 10 seconds to “fix” the split, you’re inviting abuse — short TTLs are a classic vector for DNS Amplification Attack Mitigation: Protecting CDN DNS Infrastructure headaches. Don’t go below 30 seconds unless you’ve got response rate limiting in place.

The way we troubleshoot drift is to stop looking at DNS query counts and start looking at edge request counts. Log the PoP-ID and X-Cache-Status for every request, then compare the distribution per 5-minute window against your configured weights. If you set 80/20 and see 78/22 for an hour, that’s normal variance. If you see 70/30 for 3 hours straight, you’ve got a stuck resolver or a mis-weighted record in another region. We dump dig +short from 12 vantage points via ThousandEyes every 2 minutes and alert if the observed ratio deviates by more than ±7% from expected for 15 minutes.

Frequently Asked Questions

Can I use weighted routing for a gradual CDN migration without downtime?

Yep, that’s its best use case. Start with 95/5 or 98/2 to the new CDN, monitor origin offload, cache hit ratio, and p95 TTFB on both sides for 24 hours, then step weights by 15-25% increments. Keep TTLs at 60 seconds during the migration and use health checks so traffic automatically fails back if the new edge starts 5xxing. Don’t delete the old records until you’ve run at 0/100 for at least 2x TTL.

Why doesn’t my traffic match the weights exactly, even with a low TTL?

Because DNS isn’t a load balancer — it’s a suggestion cached by thousands of recursive resolvers you don’t control. TTL only controls how long a resolver can cache, not how long a client keeps a connection open. Expect 5-10% variance on a 90/10 split with a 60-second TTL, and up to 15% variance with a 300-second TTL. If you need precise per-request splitting, do it at L7 with a reverse proxy or CDN load balancer, not DNS.

Should I use weighted routing or latency-based routing for CDN distribution?

Different jobs. Use weighted for proportional control — canaries, A/B tests, or cost-based distribution where you want exactly 30% on cheaper transit. Use latency-based (or geoproximity) when you want the fastest PoP to win automatically. A lot of teams stack them: latency-based at the top level, then weighted inside each region for canaries. Just don’t combine weighted and failover records with the same set_identifier — you’ll create an ambiguous routing policy that most providers will reject.

How do health checks interact with zero-weight records?

A record with weight 0 gets no traffic but will still be health-checked if a check is attached. That’s useful — it lets you keep a warm standby that’s validated as healthy, so you can flip it to weight 10 or 50 instantly without waiting for health check convergence, which typically takes 20-30 seconds. If you remove the health check from a zero-weight record, some providers won’t mark it unhealthy, so a flip could send traffic to a dead endpoint.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker