Last year I watched a launch go sideways because a user in Quebec got redirected to a French-France version of the site that used the wrong currency and date formats. The CDN was doing exactly what it was told — routing by country — but nobody had thought about how language preferences actually work in multilingual regions. If you're serving a global audience, getting CDN Content Lifecycle Management: From Upload to Expiration and Archival right means nothing if the wrong language shows up first.
Why Accept-Language Alone Fails for Real-World Content Localization
For years, the standard advice for CDN i18n was simple: respect the Accept-Language header and cache variations with Vary. In theory, it’s clean and follows the HTTP Working Group Specifications to the letter. The browser tells you what the user wants, you serve it, and the CDN caches each language variant separately. I've set this up dozens of times and it works beautifully — until it doesn't. The problem is that Accept-Language is often wrong, incomplete, or just missing entirely.
I've seen this firsthand on a large e-commerce platform with about 12 million monthly users across Europe. Around 40% of requests from Switzerland and Belgium had Accept-Language headers set to just en-US because of corporate device images and default browser installs. We were faithfully serving English to folks who would have preferred French or German. On top of that, search engine crawlers rarely send meaningful Accept-Language headers, which meant our carefully translated pages weren't getting indexed properly in local markets. You can't build a reliable multi-language delivery strategy on a header that users don't control and don't even know exists. That's why most modern setups I've worked on treat Accept-Language as a hint, not a rule.
There's also the SEO and sharing angle that web.dev Articles cover really well — you want distinct, cacheable URLs for each locale. When you only vary by header, you end up with one URL returning different content, which is a nightmare for debugging and for social previews. We've moved toward a hybrid model where URL structure carries the primary locale signal, and the header is used for initial routing and fallback. It gives you deterministic caching and a much better experience when someone shares a link.
# VCL-style logic for dignified content negotiation
# Don't trust Accept-Language blindly - combine with URL
sub vcl_recv {
# 1. URL path is the source of truth if present (/fr-ca/, /de/)
if (req.url ~ "^/([a-z]{2})(-([a-z]{2}))?/") {
set req.http.X-Locale = regsub(req.url, "^/([a-z]{2}(-[a-z]{2})?)/.*", "\1");
} else {
# 2. Fallback to Accept-Language parsing with quality values
# Example: Accept-Language: fr-CH, fr;q=0.9, en;q=0.8
if (req.http.Accept-Language ~ "fr") {
set req.http.X-Locale = "fr";
} else if (req.http.Accept-Language ~ "de") {
set req.http.X-Locale = "de";
} else {
set req.http.X-Locale = "en";
}
# 3. Normalize and redirect to canonical localized URL
# Avoid serving different content on the same URL
}
# Important: Vary on X-Locale, not directly on Accept-Language
set req.http.Vary = "X-Locale";
}
sub vcl_hash {
# Include locale in cache key - more on this later
hash_data(req.http.X-Locale);
hash_data(req.url);
}
Implementing Geographic Language Routing That Doesn't Annoy Users
Geographic language routing sounds straightforward — map a country to a language and redirect — but anyone who's actually run it at the edge knows it's full of edge cases. I made the classic mistake back in 2019 of forcing a 302 redirect based solely on MaxMind GeoIP country. Visitors from Canada got forced to en-CA, which infuriated our French-Canadian customers. We saw bounce rates jump 22% in Quebec during that week. The lesson stuck with me: geography should suggest, never force. The best implementations I've built since then use geographic data to set a sensible default while always honoring an explicit user choice stored in a cookie or URL.
The pattern that works reliably is a tiered decision at the edge worker, before you ever hit origin. First, check for a user preference cookie (like preferred_locale=fr-CA) or a path prefix (/fr-ca/). If that's present, respect it completely. If not, look at the CloudFront-Viewer-Country or CF-IPCountry header that your CDN injects. Use that to pick a default locale table — for example, CH -> de-CH, fr-CH, it-CH with a priority list — and then intersect that with the Accept-Language header to pick the best match. Only if all signals fail do you fall back to your global default. And please, don't do an automatic redirect on every request. We now return a 200 with a gentle banner or use a 302 only on the root path / for first-time visitors. It keeps caches cleaner and doesn't break the back button.
When you pair this with smart deployment practices, it becomes a lot safer to iterate. We tie our locale routing tables to versioned configs so we can roll them back instantly. I always recommend reading through Content Versioning Patterns for CDN: Atomic Deploys and Rollback-Ready Assets before you push routing changes live, because a bad regex in your locale detection can poison your cache for hours. A similar principle applies to cache freshness — you don't want a stale redirect cached for a user who just switched languages, so your rules need to align with CDN Content Expiration Policies: Setting Freshness Rules That Match Content Dynamics.
// Cloudflare Worker / Fastly Compute@Edge - Geographic Language Routing
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const cookieLocale = request.headers.get('Cookie')?.match(/locale=([a-z-]+)/)?.[1];
const country = request.cf?.country || request.headers.get('CF-IPCountry') || 'US';
const acceptLang = request.headers.get('Accept-Language') || '';
// 1. Explicit user choice always wins
if (url.pathname.match(/^\/(en|fr|de|it|es|ja)(\/|$)/) || cookieLocale) {
return fetch(request); // Already localized or has preference
}
// 2. Country-to-locale map with fallbacks
const geoMap = {
'CH': ['de-CH', 'fr-CH', 'it-CH', 'en'],
'CA': ['en-CA', 'fr-CA'],
'BE': ['nl-BE', 'fr-BE', 'de-BE'],
'US': ['en-US'], 'FR': ['fr-FR'], 'DE': ['de-DE']
};
const candidates = geoMap[country] || ['en'];
// 3. Intersect with Accept-Language for best match
let chosen = candidates[0];
for (const lang of candidates) {
if (acceptLang.toLowerCase().includes(lang.split('-')[0])) {
chosen = lang;
break;
}
}
// Only redirect on root, and make it cacheable but short
if (url.pathname === '/') {
url.pathname = `/${chosen.toLowerCase()}/`;
return Response.redirect(url.toString(), 302);
}
// For other paths, set header for origin and for Vary
request.headers.set('X-Geo-Locale', chosen);
return fetch(request);
}
}
Cache Key Strategies and the Real Cost of Getting i18n Wrong
Here's where content localization can quietly destroy your CDN performance if you're not careful. Every dimension you add to your cache key — language, country, currency — multiplies your cache variants and can crater your hit ratio. I audited a media site last year that was keying on Accept-Language + Country + Cookie, creating over 8,000 variants for the homepage. Their global hit ratio was under 68%. We simplified it to a normalized locale key (just en, fr, de) with separate handling for currency, and the hit ratio jumped back to 91% overnight. You don't need to cache every possible combination, you need to cache the useful ones.
The trick is normalization. Don't cache fr-FR, fr-CA, and fr-CH as three separate objects if the translation is identical. We maintain a locale alias map at the edge: fr-CA and fr-FR both collapse to fr for most pages, except for checkout and legal pages where regional differences matter. That decision alone cut our originShield traffic by nearly a third. You also need to be deliberate about your Vary header. Using Vary: Accept-Language, Accept-Encoding, Cookie is a sure way to fragment your cache. Instead, normalize to a single X-Locale header at the edge and then Vary only on that. It’s cleaner, more debuggable, and plays much better with tiered caching.
I've learned to test these trade-offs with real numbers before committing. Don't guess — run a load test with your actual traffic mix and watch origin load, not just hit ratio. The table below shows what we measured during that media site optimization across three PoPs in Europe over a 7-day period with identical traffic. It's a good reference for what to expect when you tune your multi-language setup.
| Cache Key Strategy | Cache Hit Ratio | Avg. TTFB (Edge) | Origin Requests / min | Storage Variants |
|---|---|---|---|---|
No Locale in
Implementing Locale Negotiation at the Edge: A Worker-Based BlueprintAlright, let's get our hands dirty. The cleanest place to do language negotiation isn't your origin — it's at the edge, before the cache lookup ever happens. If you let origin handle fallback logic, you're paying 80-120ms of extra TTFB on every cache miss plus burning origin compute for something a Worker can do in under 1ms. The pattern that actually holds up at scale is a two-tier decision: use GeoIP as a hint, validate it against Accept-Language, then normalize to a canonical locale before you touch the cache key. We don't want to fragment your cache into 200+ variants, either. Most global sites only support 8-12 real locales — en-US, en-GB, fr-FR, de-DE, es-ES, es-MX, pt-BR, ja-JP, etc. — but browsers will send you garbage like Here's the Cloudflare Worker pattern we've standardized on after testing it across ~40B requests/month. It does three things: 1) resolves country to a default locale map, 2) parses and weights Accept-Language properly, and 3) sets a normalized
Advanced Patterns: Fallbacks, Cache Poisoning, and When GeoIP Gets It WrongGeoIP lies. More than you think. About 4-7% of the time, you'll route a French user in Montreal through a US PoP, or a VPN user in Berlin will show up as Singapore. If your logic is The other killer is locale-based cache poisoning. Say your origin returns a 302 to Finally, watch your consistency model. If you're purging or updating localized content, a purge for Frequently Asked QuestionsShould I use URL path (/fr/), subdomain (fr.example.com), or headers for locale?Use URL path. It's the only method that's cache-friendly, SEO-correct (you can hreflang it properly), and debuggable. Subdomains fragment your TLS and cache, headers are invisible and unshareable. We've seen path-based routing maintain 92-95% hit ratios, while header-only negotiation drops to 78-82% because CDNs struggle to vary correctly. Path also gives users a shareable, bookmarkable URL. Always 302 redirect the root to the negotiated locale path rather than serving different content on the same URL. How do I prevent my cache hit ratio from tanking with 12 locales?You won't have 12x the objects if you do it right — you'll have roughly 1.2-1.4x. The trick is to only vary on normalized locale after collapsing Accept-Language, not the raw header, and don't create locales you don't have content for. Don't do What's the best way to handle users who VPN or travel?Don't fight them. Store their explicit choice in a cookie and respect it for 30+ days. If a German user with Do I need to translate every asset, or can I share images/videos across locales?Share them. Only text-heavy assets (HTML, JSON copy, metadata) need locale variants. Images, fonts, JS bundles, and video should be locale-agnostic and cached once globally with Related Articles
References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker |