Geo-Targeted Traffic Routing
Geo-targeted traffic routing directs each request to region-specific infrastructure based on IP geolocation, measured latency, or regulatory boundaries — and the layer you choose to make that decision determines your failover speed, compliance posture, and cache efficiency.
Core Implementation Considerations:
- IP geolocation databases versus real-time latency probing trade-offs
- DNS TTL limitations versus edge middleware flexibility for failover speed
- Data residency compliance mandates (GDPR, CCPA, LGPD) and where to enforce them
- Header injection and cache-key partitioning so geo decisions survive the CDN
Modern architectures have shifted from static DNS steering toward dynamic Edge Routing & Serverless Function Architecture for sub-millisecond decision making at the network boundary. This guide walks through the decision layers, provider-specific routing logic, the cache and TTL implications that bite teams in production, and the debugging and rollback workflows that keep a regional cutover from becoming an outage.
Architecture & Routing Strategy Selection
Three layers can make a geo decision, and they differ in granularity, speed, and what signal they actually see.
Authoritative DNS maps the client’s recursive resolver IP to a region and returns region-specific records. It operates entirely at the network layer, so it has no visibility into application latency or session state, and — critically — it sees the resolver IP, not the end-user IP. A user in Frankfurt querying through a centralized corporate or public resolver in Amsterdam may be steered to the wrong region. DNS is the right tool for coarse, infrastructure-level placement, but its caching behavior makes it slow to react.
Edge compute intercepts the request after the TCP/TLS handshake, when the CDN already knows the true client IP and has resolved a country code. Evaluate Cloudflare Workers Routing for dynamic path rewriting and origin selection without an origin round-trip. Isolate-based runtimes add negligible latency while enabling session-aware logic, header inspection, and immediate URL manipulation. This is where fine-grained routing, locale injection, and compliance gating belong.
Application origin is the slowest and most expensive place to route — by the time the request reaches it, you have already paid the cross-region network cost, and any redirect you issue forces a second full round-trip. Use it only for business logic that genuinely cannot be resolved earlier, such as routing tied to an authenticated account’s home region rather than to network geography.
The durable pattern is hybrid: DNS or Load Balancing at the Edge for coarse infrastructure placement and health-based failover, edge middleware for the per-request decision, and the origin for nothing geo-related. Map each compliance requirement explicitly to a layer rather than scattering country checks across the stack — a single country test that lives in three different codebases will drift, and the drift surfaces as a residency violation rather than a 500.
Choosing between geolocation and latency as the primary signal is a deliberate trade-off. Geolocation answers the question “where is this user, legally?” and is precise to a country, which is what residency rules demand. Latency answers “which region serves this user fastest?” and may send a user in Lisbon to a Madrid origin that is geographically correct but, on a given network path, slower than Paris. When the two signals disagree, residency must win for regulated payloads and latency may win for everything else — encode that precedence explicitly rather than letting whichever check runs last decide.
Validation Commands:
dig +trace app.example.com
curl -sI https://app.example.com
Expected Output: dig +trace reveals the resolver delegation path and the records each level returns. curl -sI returns HTTP/2 200 with regional markers such as x-routed-region: eu-central-1 and a cf-ray whose colo suffix (for example FRA) confirms which edge location served the request.
Provider Implementations
Cloudflare: read request.cf.country, select origin
Cloudflare exposes geolocation on the native request.cf object, which is more reliable than the cf-ipcountry request header because it cannot be spoofed by an upstream client. Country codes are ISO 3166-1 alpha-2 (DE, FR, GB) — never EU.
export default {
async fetch(request, env, ctx) {
const country = request.cf?.country || 'US';
const EU = new Set(['DE', 'FR', 'IT', 'ES', 'NL', 'PL', 'SE', 'BE', 'AT', 'FI', 'IE', 'PT', 'DK', 'GR', 'CZ']);
const originHost = EU.has(country) ? 'eu-origin.example.com' : 'global-origin.example.com';
const url = new URL(request.url);
url.hostname = originHost;
const resp = await fetch(new Request(url, request), {
cf: { cacheKey: `${originHost}:${url.pathname}` }
});
const out = new Response(resp.body, resp);
out.headers.set('x-routed-region', EU.has(country) ? 'eu-central-1' : 'us-east-1');
out.headers.set('Vary', 'CF-IPCountry');
return out;
}
};
This rewrites the request server-side — no client-visible redirect — and partitions the cache by region so EU and global responses never collide. See Implementing Geo-Routing with Edge Functions for Latency Reduction for benchmarking the isolate execution cost of this pattern.
AWS: Route 53 geolocation and latency policies, Lambda@Edge for the per-request branch
Route 53 offers two distinct geo policies. Geolocation routing answers based on the resolver’s mapped continent/country and is the correct tool for hard data-residency boundaries. Latency routing answers with the lowest-latency region for that resolver and is the right tool for performance. Always pair both with health checks and a default record so an unmapped location still resolves.
resource "aws_route53_record" "geo_eu" {
zone_id = aws_route53_zone.main.zone_id
name = "app.example.com"
type = "A"
set_identifier = "eu"
geolocation_routing_policy {
continent = "EU"
}
health_check_id = aws_route53_health_check.eu.id
alias {
name = aws_lb.eu_central_1.dns_name
zone_id = aws_lb.eu_central_1.zone_id
evaluate_target_health = true
}
}
resource "aws_route53_record" "geo_default" {
zone_id = aws_route53_zone.main.zone_id
name = "app.example.com"
type = "A"
set_identifier = "default"
geolocation_routing_policy {
country = "*"
}
alias {
name = aws_lb.us_east_1.dns_name
zone_id = aws_lb.us_east_1.zone_id
evaluate_target_health = true
}
}
For per-request logic on CloudFront, read CloudFront-Viewer-Country in a Lambda@Edge viewer-request trigger and rewrite the origin or URI before the cache lookup.
Azure, GCP & Fastly: Traffic Manager, Cloud DNS, and VCL
Azure Traffic Manager exposes a Geographic routing method that maps the resolver to regions/countries; combine it with the Performance method in a nested profile so a request first lands in the legally correct geography and then picks the fastest endpoint within it. Note that Traffic Manager works at the DNS layer, so the same TTL caveats apply and a Geographic profile must cover every region you expect traffic from or matching requests receive no answer at all. Google Cloud DNS provides geolocation routing policies on managed zones, while a global external HTTPS load balancer steers by client region at the proxy and exposes the country to backends through the X-Client-Geo-Location header. Fastly does geo at the edge in VCL using the built-in geo-IP database, which keeps the decision on the same node that terminates TLS:
sub vcl_recv {
set req.http.X-Geo-Country = client.geo.country_code;
if (req.http.X-Geo-Country ~ "^(DE|FR|IT|ES|NL)$") {
set req.backend = F_eu_origin;
} else {
set req.backend = F_global_origin;
}
set req.http.X-Routed-Region = (req.backend == F_eu_origin) ? "eu-central-1" : "us-east-1";
}
For Next.js-native deployments, Vercel Edge Middleware reads request.geo and rewrites paths before rendering:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const country = request.geo?.country || 'US';
const locale = country === 'DE' ? 'de' : 'en';
const url = request.nextUrl.clone();
url.pathname = `/${locale}${url.pathname}`;
const response = NextResponse.rewrite(url);
response.headers.set('x-routed-locale', locale);
response.headers.set('Vary', 'x-vercel-ip-country');
return response;
}
Platform Comparison
| Provider | Mechanism | Wire behavior | Failover / Notes |
|---|---|---|---|
| Cloudflare | Worker reads request.cf.country |
Server-side fetch rewrite, no redirect |
Pair with load balancer pools for health-based origin failover; sub-ms isolate cost |
| AWS Route 53 | Geolocation or latency record set | DNS answer varies by resolver region | evaluate_target_health shifts to default record; TTL governs reaction time |
| AWS CloudFront | Lambda@Edge viewer-request |
URI/origin rewrite before cache | Per-request, sees true viewer country header |
| Azure | Traffic Manager Geographic method | DNS CNAME steering | Nest with Performance profile; endpoint monitoring for failover |
| GCP | Cloud DNS geo policy / global LB | DNS answer or proxy steering | Health checks at the LB; backend service failover groups |
| Fastly | VCL client.geo.country_code |
Backend selection at the edge | Shielding + backend .probe health checks for failover |
| Vercel | Edge Middleware request.geo |
Path rewrite pre-render | Framework-aware; locale and cache-key injection |
Configuration Procedure
- Inventory regions and the signal each needs. Separate residency boundaries (hard, country-precise, legally binding) from performance boundaries (soft, latency-driven). They use different routing policies.
- Place coarse steering. Configure DNS geolocation/latency records or an edge load balancer with one pool per region plus a catch-all default record.
- Add the per-request edge function. Read the provider country header, select the origin or locale, and write
x-routed-regionplus aVaryheader so the decision is observable and cache-safe. - Wire health checks. Attach a probe to every regional endpoint and confirm the default/fallback path is exercised when a probe fails — see Configuring Edge Health Checks and Automatic Failover.
- Set TTLs deliberately. 30–60s on geo DNS records; let the edge handle anything that must react faster.
- Deploy and verify. Push the function, then replay synthetic requests from each target country before shifting real traffic.
wrangler deploy --env production
aws route53 change-resource-record-sets \
--hosted-zone-id Z01234567890ABC \
--change-batch file://geo-routing.json
TTL, Caching & Propagation Implications
Geo-routing and caching interact in ways that silently corrupt responses if ignored. Two failure modes dominate.
Cache poisoning across regions. If a CDN caches an EU-origin response under a key that omits country, a subsequent US request can be served the EU body. Always add the country to the cache key — Vary: CF-IPCountry on Cloudflare, the equivalent header on Vercel/Fastly, or an explicit cf.cacheKey that embeds the region. This is the same partitioning discipline covered for Cache Key & Vary Configuration; geo simply adds one more dimension. Over-partitioning is the opposite hazard: varying on full country when only EU-versus-global matters multiplies your cache footprint and tanks hit ratio. Normalize to the smallest set of buckets your routing actually distinguishes.
DNS TTL versus failover speed. A 300-second TTL means clients keep hammering a dead region for up to five minutes after a health check fails, because their recursive resolver still serves the cached A record. Lower geo-record TTLs to 30–60s so the resolver refreshes quickly, and accept the higher query volume as the cost of fast regional cutover. For anything that must fail over in seconds, do not rely on DNS at all — use edge health checks plus an HTTP 302/307 redirect issued by the edge function, which takes effect on the very next request. Propagation of a geo-record change is governed by the old TTL still in resolver caches, so plan a cutover by lowering TTL well in advance of the change.
Be aware that the TTL you set is a ceiling, not a guarantee: some recursive resolvers clamp very low TTLs upward to protect themselves from query floods, and aggressive forwarders may serve a stale answer slightly past expiry. This is exactly why edge-level failover exists as a faster, resolver-independent layer. A common production arrangement keeps geo DNS records at 60s for steady-state placement while the edge function owns sub-second reactions: when the edge load balancer marks a pool unhealthy, the function immediately stops selecting that origin even though the DNS answer has not changed. The two layers cover different time scales — DNS for the minutes-long structural shift, the edge for the next-request reaction.
There is also a personalization interaction worth flagging. If your origin sets Cache-Control: private for geo-personalized responses but your edge caches them anyway because of a misconfigured override, you will leak one region’s content to another. Treat the country dimension of the cache key as load-bearing and assert it in tests, the same way you would assert authentication scoping.
Troubleshooting & Rollback
Inject mock geo headers to exercise every routing branch without physical travel, and confirm the cache status is what you expect:
curl -H 'CF-IPCountry: DE' -H 'Accept-Language: de-DE' \
-sI https://app.example.com/api/health
Expected Output: HTTP/2 200 with x-routed-region: eu-central-1 and x-cache-status: HIT (or MISS on first fetch). A DE header returning us-east-1 means the function read request.cf.country while you spoofed the header — note Cloudflare’s native object ignores the spoofed header by design, so test against a Worker route that honors the header for synthetic checks.
Analyze edge logs for header propagation, hit ratios, and fallback triggers. Filter on x-geo-fallback: true to find requests where geo data was missing, and on mismatches between x-routed-region and the colo in cf-ray to catch misrouting. A sudden spike in fallback-tagged requests from a single country usually signals a stale geolocation database or a newly allocated IP block that has not yet been classified — correlate the fallback rate against the source ASN before assuming the function logic broke.
For a controlled validation pass, drive synthetic requests from several country headers in sequence and diff the routed region against an expected map:
for c in DE FR US BR RU; do
region=$(curl -s -H "CF-IPCountry: $c" -D - -o /dev/null \
https://app.example.com/api/health | awk -F': ' '/x-routed-region/{print $2}' | tr -d '\r')
printf '%s -> %s\n' "$c" "$region"
done
Expected Output: DE -> eu-central-1, FR -> eu-central-1, US -> us-east-1, BR -> us-east-1, and RU either blocked (no region header, HTTP 403) or fallback-tagged depending on your restricted-region policy. Any country landing in an unexpected region is a routing-table bug; catch it here, not in production telemetry.
Rollback. Keep the previous Worker version and roll back instantly with wrangler rollback or wrangler deploy of the prior bundle. For DNS, revert the changed record set; because you pre-lowered the TTL, the bad answer drains within a minute. The fastest kill switch is an environment flag your function reads — set it and the edge sends every request to the single known-good global origin, bypassing all geo logic without a redeploy.
Edge Cases & Gotchas
- VPN and corporate-proxy traffic resolves to the exit-node IP, routing users to the wrong region. Mitigate with a latency fallback and an explicit user-region override cookie (
Set-Cookie: region_override=us; Path=/; Secure; SameSite=Lax) that the function honors on later requests. - Mobile roaming users carry a cell-gateway IP that mismatches their billing country. Pin the region to a session cookie after the first decision and use
Accept-Languageonly as a secondary signal — never hard-block on IP alone. - Stale geolocation databases route newly allocated IP blocks to legacy regions, risking residency violations. Prefer the provider’s live edge header over a self-hosted MaxMind copy, and audit quarterly.
EUis not a country code. Some teams test with a literalEUand silently fall through to the default branch. Always enumerate member countries in a set.- Country-level block lists belong with your security layer, not your routing layer — combine geo decisions with WAF & Rate Limiting at the Edge so that Blocking or Redirecting Traffic by Country at the Edge is enforced consistently rather than reimplemented per origin.
Fallback Configuration (YAML):
geo_fallback:
default_region: "us-east-1"
restricted_regions: ["RU", "CN"]
action: "block"
response_code: 403
on_missing_geo:
region: "us-east-1"
inject_header: "x-geo-fallback: true"
Frequently Asked Questions
How accurate is IP-based geolocation for production routing? Typically 95-98% accurate at the country level and far less at city or region level. Use it for coarse country-grained routing, then supplement with latency probes or an explicit user preference where precision actually matters.
Should I use DNS or edge middleware for geo-targeted routing? Use DNS (or an edge load balancer) for static, infrastructure-level placement and health-based failover, and edge middleware for dynamic, user-aware decisions, header injection, and framework integration. Most production stacks run both — DNS for the coarse pool, the edge for the per-request branch.
How do I keep geo-routing from poisoning my CDN cache?
Add the routing dimension to the cache key. Set Vary: CF-IPCountry (or the equivalent), or embed the region in an explicit cache key, and normalize to the smallest set of buckets your routing distinguishes so you do not shred your hit ratio.
What is the recommended fallback when geo-data is missing?
Default to the lowest-latency region or a globally compliant primary, and inject x-geo-fallback: true so downstream services and caches can adjust personalization and logging. Never let a missing signal produce an unrouted or non-compliant response.