Most teams implement country-level personalization the same way I did on my first large retail deployment: detect the country from the IP, issue a 302 to /us/ or /de/, and let the origin handle the rest. It works in a demo, and it falls apart at the edge. You fragment your cache, add a full round-trip before any meaningful content loads, and you create a maze for crawlers. Geolocation routing at the CDN edge doesn't require a redirect at all. When you rewrite or vary content directly at the edge PoP based on a reliable geo signal, you keep a single URL canonical, preserve cache efficiency, and still deliver country-specific pricing, language, compliance banners, and inventory. In my experience running this for e-commerce and media sites across Cloudflare, Fastly, and CloudFront, the no-redirect approach consistently shaves 200-400ms off LCP on cross-region visits and doubles cache hit ratios compared to redirect-based geo targeting.
Why 302 Redirects Break Cache Efficiency and User Experience
Redirect-based geo routing feels logical because it maps cleanly to application routing. A user lands on example.com/ from France, you 302 them to example.com/fr/. The problem is what happens at the CDN layer. That initial / request is essentially uncacheable for a returning user from a different country, and you now have two or three variants of what should be one resource. I audited a travel client that had 28 country paths via 302s. Their edge hit ratio for the homepage was 58%. After collapsing to a single path with edge rewriting, it jumped to 91% without changing origin capacity.
Redirects also punish performance where it matters most. A 302 requires a full TLS-terminated request to the nearest PoP, a Worker or rule execution, then another navigation. Even with a fast PoP, you are adding DNS + TCP + TLS + TTFB for the redirect target. On real mobile networks in Southeast Asia and LATAM, I've measured this as an extra 350-700ms before the browser even requests the HTML it will render. That directly impacts Core Web Vitals and conversion.
There is an SEO cost too. If you 302 based on IP, Googlebot will see inconsistent canonicals unless you carefully exempt crawlers, and hreflang becomes fragile. I've found that keeping one canonical URL per product or article and varying the rendered fragment at the edge gives crawlers a stable target while still letting users see localized pricing and language. The HTTP Vary mechanism was designed for exactly this, and the semantics are well-defined in the HTTP Working Group Specifications.
The Cost You Don't See: Cache Key Explosion
When every country gets a separate URL or a cookie-driven redirect, your cache key space explodes. With /us/, /gb/, /au/ and so on, a purge has to fan out across dozens of objects. With no-redirect logic, you can either serve a normalized cache key and assemble country-specific fragments via ESI, or you can explicitly control the variant dimension with Vary or a surrogate key. The second approach is far easier to operate.
// Cloudflare Worker: Rewrite response at the edge without redirecting
export default {
async fetch(request, env, ctx) {
const country = request.cf.country || "US";
const url = new URL(request.url);
// Never redirect, just annotate the request for origin and cache
const modifiedHeaders = new Headers(request.headers);
modifiedHeaders.set("x-geo-country", country);
modifiedHeaders.set("x-geo-routing", "edge-rewrite");
// Fetch from origin with a single canonical URL
// The origin returns base HTML; Worker injects country-specific fragments
let response = await fetch(new Request(url, {
headers: modifiedHeaders,
cf: { cacheKey: url.toString() } // Keep cache key canonical
}));
// Example: inject price and compliance banner
if (response.headers.get("content-type")?.includes("text/html")) {
response = new Response(
(await response.text())
.replace("{{PRICE_PLACEHOLDER}}", getPriceForCountry(country))
.replace("{{VAT_NOTICE}}", getVatNotice(country)),
response
);
response.headers.set("Vary", "x-geo-country, Accept-Language");
response.headers.set("Cache-Tag", `page, geo-${country}`);
}
return response;
}
}
function getPriceForCountry(cc) {
const prices = { US: "$49.00", DE: "€45,00 inkl. MwSt.", GB: "£39.00 inc. VAT", JP: "¥6,800 税込" };
return prices[cc] || prices["US"];
}
function getVatNotice(cc) {
return ["DE","FR","IT","ES","NL"].includes(cc) ? "Preis inkl. 19% MwSt." : "";
}
Decoding Geo Signals: How CDNs Resolve Country From IP at the Edge
All major CDNs expose a country code derived from MaxMind or similar GeoIP databases, usually as a two-letter ISO 3166-1 alpha-2 code. The specifics matter. Cloudflare adds request.cf.country and the CF-IPCountry header, Fastly exposes client.geo.country_code in VCL, Akamai provides AKAMAI_EDGESUITE_GEO or EdgeScape variables, and CloudFront passes CloudFront-Viewer-Country if you whitelist it. In my experience, accuracy at the country level is 97-99% for most traffic, but you will always have a long tail of anomalous IPs: satellite providers, new IP allocations, and anycast egress.
The key is to treat the geo value as a hint, not a hard identity. Don't block if the header is missing. I've seen missing or XX country codes on 0.5-2% of requests, typically from corporate proxies, emerging mobile carriers, and bots. Your edge logic should handle that explicitly. Decide your default (usually US or a neutral global variant) and log the occurrence so you can tune. If you need region or city precision for legal reasons, validate that the CDN actually provides that signal at your tier; on CloudFront it requires the additional CloudFront-Viewer-Country-Region headers to be enabled.
You should also decide where the geo signal lives for the rest of the stack. I prefer to normalize it into a single header like x-geo-country at the very first edge function, then use that header for both cache variation and origin logic. That way your origin doesn't need to know whether the traffic came from Cloudflare or Fastly, and your logs have one field to query. If you're evaluating edge compute options for this, the comparison in Edge Functions Deep Dive: Cloudflare Workers, Deno Deploy and Vercel Edge is a useful baseline for latency and runtime trade-offs.
Header Propagation and Trust Boundaries
Only trust geo headers injected by your CDN. Strip any incoming x-geo-country or CF-IPCountry from the client before you set your own, otherwise a client can spoof country to see other markets' pricing. In Workers or CloudFront Functions, overwrite unconditionally. On Fastly, do this in vcl_recv before any lookup. Log both the raw CDN header and your normalized header for auditability.
Rewriting Content Without Redirecting: The Vary and Content-Negotiation Pattern
The no-redirect pattern comes in three flavors, and you can mix them. First, full edge rewrite: the edge fetches a canonical object and mutates HTML before delivery. Second, edge includes: the edge assembles a static shell with dynamic fragments fetched from origin or KV. Third, header-based negotiation where the origin returns the correct variant based on x-geo-country and the edge caches variants separately. I've used all three; the assembly approach scales best when you have many countries but only a few components that actually vary.
Caching semantics are critical. If you return different bodies for the same URL based on country, you must signal that with Vary or you will poison the cache. For example, Vary: x-geo-country tells shared caches there are multiple representations. However, Vary on a high-cardinality header can reduce hit ratio if your cache key includes every header value literally. The better approach on modern CDNs is to keep Vary for correctness but also define an explicit cache key that includes country as a discrete dimension, or use surrogate keys to purge logically. This is where web.dev Articles on caching and view-source cache partitioning are worth revisiting.
I've found that the cleanest setup is: single canonical URL, edge sets x-geo-country, cache key is url + normalized country group, not every individual country. Group countries into price zones or legal zones. For instance, EU-VAT, UK, US-CA (for California-specific compliance), and ROW. That reduces variants from 200 to 5-7 while still meeting business rules.
# Fastly VCL: Group countries and vary without redirecting
sub vcl_recv {
# Normalize and trust only our edge signal
unset req.http.x-geo-country;
if (client.geo.country_code) {
set req.http.x-geo-country = client.geo.country_code;
} else {
set req.http.x-geo-country = "US";
}
# Map to pricing/legal zones to keep cache cardinality low
if (req.http.x-geo-country ~ "^(DE|FR|IT|ES|NL|BE|IE|PT)$") {
set req.http.x-geo-zone = "EU-VAT";
} else if (req.http.x-geo-country == "GB") {
set req.http.x-geo-zone = "UK";
} else if (req.http.x-geo-country == "US") {
# Further split US if needed for state disclosure laws
set req.http.x-geo-zone = "US";
} else {
set req.http.x-geo-zone = "ROW";
}
# Include zone in cache key (via Vary or custom key in vcl_hash)
set req.http.Vary = "x-geo-zone";
}
sub vcl_hash {
hash_data(req.url);
hash_data(req.http.x-geo-zone);
return (lookup);
}
sub vcl_deliver {
# Ensure intermediaries respect variant
if (!resp.http.Vary) {
set resp.http.Vary = "x-geo-zone, Accept-Language";
} else if (resp.http.Vary !~ "x-geo-zone") {
set resp.http.Vary = resp.http.Vary ", x-geo-zone";
}
set resp.http.Cache-Tag = "geo-" + req.http.x-geo-zone;
}
Handling Currency, Language and Legal Variants in a Single Cache Key Strategy
Country routing is rarely just about language. In practice, you are routing three distinct concerns: display language, commercial terms (currency, price, tax), and legal/compliance. Trying to tie all three to a single country enum creates fragile logic. I separate them at the edge. Language negotiation should still respect Accept-Language first, with geo as fallback. Currency should follow country zone, but allow user override via a cookie that you explicitly include in the cache key. Legal banners (GDPR, California Privacy Rights, UK cookie consent) follow a geo-legal zone.
For currency, avoid converting on the fly in the edge unless you have a fast, cached rate. Fetching an FX API per request will destroy your T50. Instead, deploy a KV or Edge Dictionary with nightly rates and do a lookup. If the rate is stale, serve the last good value and revalidate asynchronously. For VAT-inclusive pricing, render both ex-VAT and inc-VAT spans in the HTML shell and show/hide at the edge via simple string replacement or via ESI includes, so origin still emits one template.
Cache key design needs to reflect forced overrides. If a user from Germany has previously selected USD pricing, you should honor that via a cookie like pref_currency=USD. In that case, the cache key must include the preference dimension, but only when the cookie is present. I typically hash two keys: anonymous (zone only) and personalized (zone + currency). The anonymous variant serves 90%+ of traffic and stays hot in cache, while the personalized variant has a shorter TTL.
| Strategy | Cache Efficiency | TTFB Impact | Complexity at Edge |
|---|---|---|---|
| 302 Redirect to /{country}/ path | Low - duplicate objects per country, poor hit ratio on root | +300 to 700ms due to extra round-trip | Low - simple rule |
| Single URL + Vary on x-geo-country | Medium - correct but high cardinality if per-country | No extra round-trip, minimal overhead | Medium - requires Vary & key tuning |
| Single URL + Zoned Cache Key (EU/UK/US/ROW) | High - 4-7 variants cover most logic | No extra round-trip, single fetch | Medium - mapping table |
| Single URL + Edge Assembly (ESI / Includes) | Very High - shell cached globally, fragments varied | Small overhead for fragment fetch | Higher - fragment orchestration |
Surviving VPNs, Bots and Missing Geo Headers: Fallback Logic That Holds Up
Geo targeting breaks most often at the edges, literally. VPN egress can claim a user is in the US when they are in India, and bots may come from a single AS in the US regardless of their implied target. Don't try to outsmart VPNs; instead, design for override. I've found that offering a non-intrusive country switcher that sets a geo_override cookie with a 30-day TTL covers both cases. The edge should respect that cookie when present and include it in the cache key, but you should not automatically redirect based on it on the next navigation. Let the user confirm.
For bots, maintain an allowlist of verified crawlers and bypass geo personalization entirely. Serve them the canonical US/global variant with hreflang annotations and let them discover alternate language links via <link rel="alternate" hreflang="x">. If you serve bot traffic a German-priced page because Cloudflare sees a German IP for a crawl, you risk inconsistent indexing. Most CDNs expose a bot score or you can check reverse DNS; at minimum, if User-Agent contains Googlebot or bingbot, skip the country injection path.
Missing geo headers need safe defaults. On CloudFront, if you forget to add CloudFront-Viewer-Country to your cache policy allowlist, the header will never reach origin or functions. I log the percentage of XX or empty country codes as a health metric. If it spikes above 1%, something in your allowlist or header forwarding is broken. Also consider compliance: in some jurisdictions you cannot infer location without consent for analytics, but serving a VAT-inclusive price based on IP is generally considered a legitimate interest fulfillment, not tracking. Still, keep geo logic in the delivery path, not in a tracking cookie.
// CloudFront Functions (viewer-request) - normalize with safe defaults and bot bypass
function handler(event) {
var request = event.request;
var headers = request.headers;
var ua = (headers['user-agent'] && headers['user-agent'].value) || "";
// Bypass for verified crawlers - serve canonical variant
if (/Googlebot|bingbot|BingPreview/i.test(ua)) {
headers['x-geo-country'] = { value: 'US' };
headers['x-geo-zone'] = { value: 'ROW' };
headers['x-geo-bypass-reason'] = { value: 'bot-canonical' };
return request;
}
var country = headers['cloudfront-viewer-country']
? headers['cloudfront-viewer-country'].value
: 'US';
// Sanitize anomalous codes
if (country === 'XX' || country.length !== 2) country = 'US';
// Respect explicit user preference cookie if present
var cookies = headers['cookie'] ? headers['cookie'].value : '';
var m = cookies.match(/geo_override=([A-Z]{2})/);
if (m) country = m[1];
headers['x-geo-country'] = { value: country };
var euVat = { DE:1, FR:1, IT:1, ES:1, NL:1, BE:1, IE:1, PT:1 };
var zone = euVat[country] ? 'EU-VAT' : (country === 'GB' ? 'UK' : (country === 'US' ? 'US' : 'ROW'));
headers['x-geo-zone'] = { value: zone };
return request;
}
Measuring Geo-Routing Accuracy: Logging, Testing and Cache Hit Ratios
You can't improve geo logic without visibility. At the edge, emit a log line per request with x-geo-country, x-geo-zone, cache status (HIT/MISS/REVALIDATED), and whether an override cookie was present. I ship these logs to our analytics pipeline and watch three KPIs: hit ratio per zone, percentage of XX fallbacks, and zone mismatch rate (where user overrides differ from IP geo). If override rates exceed 5% in a market, your auto-detection may be too aggressive or your switcher UX is confusing.
Testing geo paths is harder than it looks because your office IP always resolves to one country. I maintain a small harness that sends requests through VPN exit nodes and also spoofs headers in staging behind an auth gate. Cloudflare and Fastly both let you set test headers in staging only, but gate that behind an allowlist of CI IPs so clients can't abuse it in production. For synthetic monitoring, I run checks from at least four regions (US, EU, APAC, LATAM) and assert that pricing strings and compliance banners match expectations, not just HTTP 200.
One technique that has paid off is segment-specific cache purging. Because we use zone-based cache tags, a EUR price update purges geo-EU-VAT only, leaving US and ROW fully hot. Combined with Edge-Side Includes in Modern CDNs: Dynamic Content Assembly at the Edge for header/footer fragments, we can update legal footers per zone without touching the product body cache. That same assembly approach makes A/B Testing at the CDN Edge: Implementation Without Origin Overhead feasible alongside geo routing: your zone is one bucket, your A/B variant is another, and the edge composes them.
Finally, document your geo decision tree for support and legal. A one-page flowchart that states: input signals (IP country, cookie override, bot bypass), grouping rules (EU-VAT vs UK), cache key composition, and fallback behavior will save you hours when marketing asks why a user in Switzerland saw German pricing.
Frequently Asked Questions
Should I use CloudFront-Viewer-Country or MaxMind directly on origin for country detection?
Use the CDN's viewer-country header at the edge. It is injected before your logic runs, adds no origin latency, and sits on the same PoP that will cache the result. Running MaxMind on origin adds egress latency and means every request has to hit origin to be personalized. If you need city-level accuracy, use the CDN's enriched headers and forward a normalized zone; don't re-resolve IP on origin unless you have a regulatory reason to log raw IP mapping yourself.
How do I avoid cache poisoning when varying by country on a single URL?
Always set Vary: x-geo-zone (or your normalized header) and ensure your CDN's cache key actually includes that zone. On Fastly, do it in vcl_hash; on Cloudflare, use Cache Rules or a custom cacheKey that includes zone; on CloudFront, include the header in your cache policy. Group countries into zones to limit cardinality, and never trust client-supplied country headers without overwriting them at the edge.
Can I still support /fr/ or /de/ paths for SEO if I don't redirect?
Yes, and you should. Keep locale-prefixed paths for indexed language variants and hreflang, but don't force users onto them via IP-based 302s. Use geo-at-edge to personalize the canonical URL (/product/shoes) and treat /fr/produit/chaussures as an explicit user choice. That gives you a stable URL for sharing and crawling, plus a localized URL for language-targeted SERPs. Canonical tags should reflect the URL the user actually requested, not the auto-redirected one.
What is a safe fallback when the geo header is missing or 'XX'?
Serve a neutral global variant, typically US English with USD pricing, and log the event. Do not block or redirect. A neutral variant keeps cache hot and avoids confusing users behind corporate proxies. If a high proportion of your traffic returns XX, audit your CDN cache policy to ensure viewer-country headers are forwarded, and check if a large share comes from bots that you should handle via a bot bypass path.