System Design ·

Designing a DNS-Based Traffic Management System: Weighted Routing, Failover Policies, and Global Load Distribution

DNS is not just name resolution. At scale, it is the first layer of traffic management, handling weighted routing, latency-based decisions, geolocation policies, and health-driven failover before a single TCP connection is established. This guide covers how it works and how to build a routing decision engine in TypeScript.

Designing a DNS-Based Traffic Management System: Weighted Routing, Failover Policies, and Global Load Distribution

Most engineers think about DNS exactly once per project: when they point a domain at a server. Then they move on to load balancers, service meshes, and CDN configurations, treating DNS as plumbing that just works.

This is a mistake. DNS is the first routing decision in any distributed system. It runs before any TCP connection is established, before any load balancer sees a packet, before any circuit breaker has a chance to intervene. At scale, DNS is where you split traffic between regions, route users to the nearest healthy endpoint, drain a datacenter without touching application code, and implement blue-green deployments at the infrastructure layer.

This article covers how DNS-based traffic management actually works: the routing algorithms, the health check machinery, the TTL tradeoffs, how Route 53, Cloudflare DNS, and NS1 implement these patterns, and a TypeScript routing decision engine that models the core logic.

Why DNS Is the Right Place for Global Traffic Decisions

The value of DNS routing is that it is universal. Every client, regardless of language, runtime, or library, resolves a hostname before connecting. This makes DNS the only mechanism that works uniformly across browsers, mobile apps, CLI tools, third-party integrations, and service-to-service calls.

Compare this to an application-layer load balancer. A load balancer works great for traffic that flows through it, but it requires clients to have already resolved the load balancer’s hostname. If the load balancer itself is in the wrong region, the damage is already done: the user has already suffered cross-regional latency before the first HTTP request. DNS routing sidesteps this entirely by directing the client toward the right region before any connection is opened.

DNS-based traffic management also decouples routing policy from application code. An on-call engineer can adjust traffic weights, trigger a failover, or drain a region from a DNS control plane without touching deployments, config maps, or feature flags.

The tradeoff is resolution time and client-side caching. DNS responses are cached by resolvers and clients according to the TTL on the record. A routing change does not take effect instantly; it propagates as TTLs expire across the resolver hierarchy. Understanding when this matters (and when it does not) is central to designing a system that uses DNS correctly.

Routing Algorithms

Weighted Round-Robin

Weighted routing assigns a numeric weight to each record in a set. The DNS resolver (or the authoritative nameserver, depending on implementation) returns records in proportion to their weights.

A common use case is a canary deployment: send 5% of traffic to a new version of your service and 95% to the stable version. Another is gradual region migration: incrementally shift traffic from an old datacenter to a new one without a hard cutover.

The resolution math is straightforward: if you have three endpoints with weights 70, 20, and 10, a resolver that respects weights will return the first endpoint approximately 70% of the time, the second 20%, and the third 10% over a large enough sample.

interface WeightedEndpoint {
  id: string;
  address: string;
  weight: number; // 0-100, relative weight
  healthy: boolean;
}

function selectWeightedEndpoint(
  endpoints: WeightedEndpoint[]
): WeightedEndpoint | null {
  const healthy = endpoints.filter((e) => e.healthy);
  if (healthy.length === 0) return null;

  const totalWeight = healthy.reduce((sum, e) => sum + e.weight, 0);
  if (totalWeight === 0) return null;

  let random = Math.random() * totalWeight;

  for (const endpoint of healthy) {
    random -= endpoint.weight;
    if (random <= 0) {
      return endpoint;
    }
  }

  // Fallback to last healthy endpoint due to floating-point edge case
  return healthy[healthy.length - 1];
}

One subtlety: when health checks mark an endpoint as unhealthy, its weight should be redistributed across the remaining endpoints proportionally. If you have weights of 50, 30, and 20 and the 50-weight endpoint goes down, the remaining endpoints should receive 60% and 40% of traffic (not 30% and 20%), because the total shifts. The function above handles this correctly by only including healthy endpoints in the weight sum.

Latency-Based Routing

Latency-based routing returns the record associated with the region that has the lowest measured round-trip time to the requesting resolver. Route 53 calls this “latency routing.” Cloudflare calls it “proximity routing” or handles it implicitly through its anycast network.

The mechanism relies on a continuously updated latency table. The DNS provider measures latency from geographic clusters of resolvers to each registered endpoint region. When a query arrives, the provider identifies the approximate geographic origin of the query (based on the resolver’s IP) and returns the record for the region with the lowest latency to that resolver cluster.

type Region = "us-east-1" | "eu-west-1" | "ap-southeast-1";

interface LatencyRecord {
  region: Region;
  address: string;
  healthy: boolean;
}

// Latency in milliseconds from resolver geographic cluster to each region.
// In a real system, this table is populated by the DNS provider's measurement infrastructure.
type LatencyTable = Record<Region, number>;

function selectLowestLatencyEndpoint(
  records: LatencyRecord[],
  resolverLatencies: LatencyTable
): LatencyRecord | null {
  const healthy = records.filter((r) => r.healthy);
  if (healthy.length === 0) return null;

  return healthy.reduce((best, current) => {
    const bestLatency = resolverLatencies[best.region] ?? Infinity;
    const currentLatency = resolverLatencies[current.region] ?? Infinity;
    return currentLatency < bestLatency ? current : best;
  });
}

Latency-based routing is the right default for global applications where user experience matters. For API services, reducing latency on the DNS layer means the first connection is already close to optimal. The remaining latency is dominated by TLS handshake, TCP setup, and application processing time.

Geolocation-Based Routing

Geolocation routing uses the origin IP of the DNS resolver to map the request to a geographic region (country, continent, or state in some implementations). This is distinct from latency-based routing: latency is empirical and can cross geographic boundaries, while geolocation is rule-based.

The primary use case for geolocation routing is data residency compliance. If EU users must be served by EU infrastructure for GDPR reasons, geolocation routing ensures all queries from European resolvers resolve to EU endpoints. Latency routing might accidentally route a French user to us-east-1 if a temporary measurement artifact shows lower latency there.

interface GeoRecord {
  region: Region;
  address: string;
  healthy: boolean;
  continents: string[]; // e.g., ["EU", "AF"] for endpoints serving those continents
  countries?: string[]; // ISO 3166-1 alpha-2, for country-level overrides
  isDefault: boolean; // catch-all for unmatched queries
}

function selectGeoEndpoint(
  records: GeoRecord[],
  resolverContinent: string,
  resolverCountry: string
): GeoRecord | null {
  const healthy = records.filter((r) => r.healthy);

  // Country-level match takes priority over continent-level
  const countryMatch = healthy.find(
    (r) => r.countries?.includes(resolverCountry)
  );
  if (countryMatch) return countryMatch;

  // Continent-level match
  const continentMatch = healthy.find((r) =>
    r.continents.includes(resolverContinent)
  );
  if (continentMatch) return continentMatch;

  // Default record catches everything else
  return healthy.find((r) => r.isDefault) ?? null;
}

One failure mode: if geolocation data is wrong or a VPN changes the apparent resolver location, users may be routed to a suboptimal or incorrect region. Defense is to always have a default record that covers unmatched cases, so queries that fall through geo rules still resolve to something healthy.

Health-Check-Driven Failover

Health check failover is the most operationally critical routing policy. The DNS provider continuously probes registered endpoints. When an endpoint fails health checks for a configured number of consecutive intervals, its DNS record is suppressed, and traffic shifts to the remaining healthy endpoints.

Route 53 supports HTTP, HTTPS, TCP, and string-match health checks with configurable intervals (10s or 30s), failure thresholds, and latency tracking. NS1 supports a similar set plus BGP monitoring. Cloudflare’s load balancing product integrates health checks directly with its DNS infrastructure.

interface HealthCheckConfig {
  endpoint: string;
  protocol: "HTTP" | "HTTPS" | "TCP";
  path?: string; // for HTTP/HTTPS checks
  port: number;
  intervalSeconds: 10 | 30;
  failureThreshold: number; // consecutive failures before marking unhealthy
  successThreshold: number; // consecutive successes before marking healthy again
  expectedStatusCode?: number;
  stringMatch?: string; // substring the response body must contain
}

interface HealthState {
  endpointId: string;
  healthy: boolean;
  consecutiveFailures: number;
  consecutiveSuccesses: number;
  lastCheckAt: Date;
  lastStatusCode?: number;
}

function evaluateHealthTransition(
  current: HealthState,
  checkPassed: boolean,
  config: HealthCheckConfig
): HealthState {
  if (checkPassed) {
    const consecutiveSuccesses = current.consecutiveSuccesses + 1;
    const healthy =
      !current.healthy && consecutiveSuccesses >= config.successThreshold
        ? true
        : current.healthy;

    return {
      ...current,
      healthy,
      consecutiveSuccesses,
      consecutiveFailures: 0,
      lastCheckAt: new Date(),
    };
  } else {
    const consecutiveFailures = current.consecutiveFailures + 1;
    const healthy =
      current.healthy && consecutiveFailures >= config.failureThreshold
        ? false
        : current.healthy;

    return {
      ...current,
      healthy,
      consecutiveFailures,
      consecutiveSuccesses: 0,
      lastCheckAt: new Date(),
    };
  }
}

The failureThreshold and successThreshold values deserve attention. A low failure threshold (1-2) triggers failover quickly but can cause false positives from transient network hiccups. A higher threshold (3-5) reduces false positives but means a genuinely degraded endpoint keeps receiving traffic longer. Route 53 recommends 3 consecutive failures as a starting point. Set the success threshold higher than the failure threshold to prevent flapping: require 5 consecutive successes before re-enabling an endpoint that was recently unhealthy.

Combining Policies: A Routing Decision Engine

Real systems layer these policies. A typical setup routes by geolocation first (compliance), then by latency within the allowed region set, then falls back to weighted round-robin across regions, with health checks suppressing unhealthy endpoints at every layer.

interface RoutingPolicy {
  type: "geo" | "latency" | "weighted" | "failover";
  priority: number; // lower number = evaluated first
}

interface Endpoint {
  id: string;
  address: string;
  region: Region;
  weight: number;
  healthy: boolean;
  continents: string[];
  countries?: string[];
  isDefault: boolean;
}

interface RoutingContext {
  resolverContinent: string;
  resolverCountry: string;
  resolverLatencies: LatencyTable;
}

function resolveEndpoint(
  endpoints: Endpoint[],
  context: RoutingContext,
  policies: RoutingPolicy[]
): Endpoint | null {
  const sortedPolicies = [...policies].sort((a, b) => a.priority - b.priority);

  let candidates = endpoints.filter((e) => e.healthy);

  for (const policy of sortedPolicies) {
    if (candidates.length === 0) break;

    switch (policy.type) {
      case "geo": {
        // Narrow candidates to geo-allowed regions
        const countryMatch = candidates.filter((e) =>
          e.countries?.includes(context.resolverCountry)
        );
        const continentMatch = candidates.filter((e) =>
          e.continents.includes(context.resolverContinent)
        );
        const defaults = candidates.filter((e) => e.isDefault);

        // Use the most specific geo match, but only narrow if matches exist
        if (countryMatch.length > 0) {
          candidates = countryMatch;
        } else if (continentMatch.length > 0) {
          candidates = continentMatch;
        } else if (defaults.length > 0) {
          candidates = defaults;
        }
        break;
      }

      case "latency": {
        // Select the single lowest-latency candidate
        const best = candidates.reduce((prev, curr) => {
          const prevLat = context.resolverLatencies[prev.region] ?? Infinity;
          const currLat = context.resolverLatencies[curr.region] ?? Infinity;
          return currLat < prevLat ? curr : prev;
        });
        candidates = [best];
        break;
      }

      case "weighted": {
        // Select one endpoint proportionally by weight
        const selected = selectWeightedEndpoint(candidates);
        candidates = selected ? [selected] : [];
        break;
      }

      case "failover": {
        // Return first healthy endpoint (sorted by priority externally)
        candidates = candidates.slice(0, 1);
        break;
      }
    }
  }

  return candidates[0] ?? null;
}

This models how Route 53 processes routing policies: each layer filters or selects from the candidate set, with health checks applied at every step.

TTL Tradeoffs

TTL (time to live) is the number of seconds a DNS response can be cached by resolvers and clients. It is the central tension in DNS traffic management: long TTLs reduce load on authoritative nameservers and improve resolution speed for end users, but they delay routing changes.

TTL ValuePropagation TimeBest For
30-60s0.5-1 min practicalActive failover, canary deployments, hot migrations
300s (5 min)5-10 min practicalNormal production traffic with infrequent changes
3600s (1 hr)1-2 hr practicalStable, rarely changed records (MX, SPF, DKIM)
86400s (24 hr)Up to 24 hrStatic infrastructure that never changes

The “practical” propagation time is longer than the TTL because not all resolvers respect TTLs exactly. Some public resolvers (Google 8.8.8.8, Cloudflare 1.1.1.1) are generally well-behaved, but ISP resolvers often over-cache. Plan for up to 2x the TTL as real-world propagation time.

For systems that need rapid failover, the right approach is not to set a 30-second TTL in production all the time. That increases authoritative query volume significantly and creates cache misses that slow resolution for end users. Instead, lower the TTL 10-15 minutes before a planned change, make the change, then raise the TTL again after the old TTL has fully expired. For unplanned failovers, the health-check machinery handles the record suppression server-side; clients with cached responses will continue hitting the old address, but health check intervals are typically 10-30 seconds, so the DNS change happens quickly even if some clients cache stale data briefly.

How Route 53, Cloudflare DNS, and NS1 Implement These Patterns

Route 53 exposes routing policies as first-class record properties: Simple, Weighted, Latency, Geolocation, Geoproximity (with a bias parameter), Failover, and Multivalue Answer. Health checks are separate resources that you associate with records. This explicit policy model is verbose in IaC (each endpoint requires its own record with a set_identifier) but gives fine-grained control. Route 53’s health checks support calculated health (aggregate of child checks), which lets you model “region is healthy only if 80% of its instances are healthy.”

Cloudflare DNS handles global routing differently. Its anycast network routes traffic to the nearest Cloudflare PoP automatically, so latency-based routing is implicit for anything proxied through Cloudflare (orange-cloud records). For non-proxied records, Cloudflare’s Load Balancing product (a paid add-on) provides weighted pools, health checks, geographic steering, and proximity steering. The operational model is simpler than Route 53’s record-per-endpoint approach: you define pools of origins and attach a load balancer to a hostname.

NS1 is the most programmable of the three. Its Filter Chain model lets you build a routing pipeline by composing filters: geolocation, up/down health, load, weighted shuffle, and custom metadata filters. The order of filters in the chain determines priority. This maps closely to the resolveEndpoint function above. NS1 also supports Data Feeds, which let external systems push real-time signals (server load, connection count, custom metrics) into the routing decision. This enables routing decisions based on actual capacity, not just reachability.

Production Considerations

Resolver IP accuracy. The address of the recursive resolver is not the same as the user’s IP. A user in Frankfurt might be using Google’s resolver at 8.8.8.8 (Virginia), which would make geolocation routing assign them to North America. EDNS Client Subnet (ECS) partially solves this: resolvers that support ECS forward a truncated version of the client IP with queries, and authoritative nameservers can use it for more accurate routing. Google, Cloudflare, and most enterprise resolvers support ECS. ISP resolvers are inconsistent.

Split-horizon DNS. Internal services should not route through public DNS. Use a private hosted zone (Route 53 Private Hosted Zones, or internal resolvers with Unbound) so internal service-to-service calls resolve to private addresses and do not leave the VPC. Traffic that traverses a public DNS record to re-enter the same network wastes bandwidth and adds latency.

Canary deployments with weighted records. Weighted routing is safe for gradual rollouts but does not give you control over which users are in the canary group. Since DNS is stateless, the same user may be routed to different endpoints on different lookups (especially on TTL expiry). If you need sticky canary assignment, use application-layer feature flags in combination with DNS routing, not DNS alone.

Health check endpoint design. The health check path should reflect actual application readiness, not just HTTP 200 from a static file. A good health endpoint checks database connectivity, cache reachability, and any critical downstream dependencies. If a health endpoint is too shallow, a partially degraded instance stays in rotation and degrades the user experience without triggering failover. If it is too aggressive (checking every downstream service), a transient dependency blip causes unnecessary failovers.

Monitoring DNS routing decisions. DNS query logs (Route 53 Resolver Query Logging, Cloudflare DNS Logs) show which records were returned for each query, which lets you verify routing policies are working as expected. Alert on the number of healthy records per routing policy: if a weighted record set drops to one healthy endpoint, you are one failure away from full outage.

ConcernRoute 53Cloudflare LBNS1
Routing policies7 policy types per recordPool-based with steering modesComposable filter chain
Health check interval10s or 30s15s or 60s15s to 5 min
ECS supportYes (reads ECS for geo/latency routing)YesYes
Programmatic routingLimited (metadata on records)Cloudflare Workers for custom logicData Feeds + metadata filters
Pricing modelPer record + per query + per health checkPer-hostname load balancer + usagePer DNS query + health checks

Closing Insight

DNS-based traffic management is not a replacement for application-layer load balancing or service mesh traffic control. It is a complement that operates at a different layer of the stack and solves a different set of problems. An L7 load balancer cannot help a user in Singapore whose TCP connection is already terminating in us-east-1. DNS can.

The systems that handle this well share a common pattern: they treat DNS records as configuration that changes under operational pressure, lower TTLs proactively before changes, drive failover off health checks rather than manual intervention, and monitor the state of their record sets with the same rigor they apply to application metrics. DNS changes are slow to propagate and invisible in most dashboards until something breaks. Treat them like database migrations: intentional, rehearsed, and always reversible.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.