CDN Content Localization: Multi-Language Delivery with Geographic Routing

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 Blueprint

Alright, 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 en-US,en;q=0.9,fr;q=0.8 or just *. Your edge logic needs to collapse that chaos into a finite allowlist. If you don't, we've seen cache hit ratios crater from 94% to 61% overnight because every unique Accept-Language string created its own object. That's how you turn a 12ms edge hit into a 450ms origin fetch for 30% of your traffic. A solid CDN Content Taxonomy: Organizing Assets for Efficient Edge Delivery and Invalidation is critical here — your locale shouldn't be a query param you tack on; it should be a first-class, normalized dimension in your cache hierarchy like /fr-fr/pricing rather than /pricing?lang=fr.

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 X-Locale header and Vary correctly without destroying your hit ratio:

// Edge locale negotiation - Cloudflare Worker / Fastly Compute@Edge compatible
const LOCALE_MAP = {
  'US': 'en-US', 'GB': 'en-GB', 'FR': 'fr-FR',
  'DE': 'de-DE', 'ES': 'es-ES', 'MX': 'es-MX',
  'BR': 'pt-BR', 'JP': 'ja-JP', 'CA': 'en-CA'
};
const SUPPORTED = new Set(Object.values(LOCALE_MAP));

function negotiateLocale(request) {
  const country = request.cf?.country || 'US';
  const geoDefault = LOCALE_MAP[country] || 'en-US';
  
  const acceptLang = request.headers.get('Accept-Language') || '';
  // Parse q-values: en-US;q=0.9, fr;q=0.8 -> sorted list
  const prefs = acceptLang.split(',').map(s => {
    const [lang, q = 'q=1'] = s.trim().split(';');
    return { lang: lang.toLowerCase(), q: parseFloat(q.split('=')[1]) || 1 };
  }).sort((a, b) => b.q - a.q);

  for (const pref of prefs) {
    // Exact match first, then language-only fallback (e.g., 'fr' -> 'fr-FR')
    if (SUPPORTED.has(pref.lang)) return pref.lang;
    const base = [...SUPPORTED].find(l => l.startsWith(pref.lang.split('-')[0]));
    if (base && pref.q > 0.5) return base;
  }
  return geoDefault;
}

export default {
  async fetch(request, env, ctx) {
    const locale = negotiateLocale(request);
    // Don't forward raw Accept-Language to cache key - normalize it
    let newHeaders = new Headers(request.headers);
    newHeaders.set('X-Locale', locale);
    newHeaders.delete('Accept-Language'); // prevents cache fragmentation
    
    let newRequest = new Request(request, { headers: newHeaders });
    let response = await fetch(newRequest);
    
    // Critical: Vary on the normalized header, not the raw one
    response = new Response(response.body, response);
    response.headers.set('Vary', 'X-Locale, Accept-Encoding');
    response.headers.set('Content-Language', locale);
    response.headers.set('Cache-Key', `locale=${locale}`); // for debugging
    return response;
  }
}

Advanced Patterns: Fallbacks, Cache Poisoning, and When GeoIP Gets It Wrong

GeoIP 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 if country == DE serve de-DE with no override, you'll actively annoy those users and you'll poison your analytics. The fix isn't to ignore GeoIP — it's to treat it as the lowest-priority signal. Our hierarchy is: 1) explicit user choice (cookie user_locale or /fr/ path prefix) always wins, 2) Accept-Language with q > 0.7, 3) GeoIP default. And you *must* expose a visible language switcher that sets that cookie with Max-Age=31536000; Path=/; SameSite=Lax. Don't rely on automatic detection alone; give users an escape hatch that persists at the edge.

The other killer is locale-based cache poisoning. Say your origin returns a 302 to /en-us/ for missing locales, but your edge caches that 302 under the wrong key. Now every de-DE request gets a redirect loop. We've debugged this twice this quarter. The rule: never cache a redirect or 404 without including the locale in the cache key *and* setting a much shorter TTL — like 60 seconds vs. 24 hours for 200s. Also, when you're federating content from multiple origins — say, your CMS in Frankfurt serves de-DE while your headless commerce API in Virginia serves en-US — you need locale-aware origin routing. That's where CDN Content Federation: Aggregating Content from Multiple Origins into Unified Delivery becomes non-negotiable. Use X-Locale to map to the correct origin group at the edge, otherwise you'll add 180ms of cross-region latency on every miss.

Finally, watch your consistency model. If you're purging or updating localized content, a purge for /pricing in en-US shouldn't nuke fr-FR, but an update to a shared asset like /images/logo.svg must. Most teams get this wrong and end up with stale French content for 3-4 hours after an English deployment. You need surrogate keys like surrogate-key: pricing en-US product-123 and purge by key, not URL. If you're running active-active origins, understand whether you're on eventual or causal consistency — serving a stale locale for 15 seconds is fine, serving the wrong language for 15 minutes isn't. For a deeper dive on that tradeoff, see CDN Content Replication and Consistency Models: Eventual, Strong, and Causal. Pro tip: add ~45 bytes of debugging headers (X-Cache-Status, X-Locale-Source: geo|accept|cookie) — it'll save you 6 hours the next time marketing says "users in Quebec are seeing English."

Frequently Asked Questions

Should 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 Vary: Accept-Language, Cookie, X-Country. Do Vary: X-Locale where X-Locale is 1 of 12 values you control. Also, set a default locale object as your fallback key. For anonymous traffic, which is 60-70% of most sites, you'll still hit the shared cached object for that locale/PoP.

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 user_locale=de-DE pops up in a Tokyo PoP, serve de-DE — don't "correct" them to ja-JP because GeoIP says so. Our edge logic checks for the cookie first and skips GeoIP entirely if it's present. That one check eliminates 90% of "wrong language" support tickets. And always make the language switcher a POST or JS-set cookie, not a redirect loop.

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 Vary: Accept-Encoding only. Use surrogate keys to tag them as shared-asset so one purge updates all locales, but don't put them under /fr-fr/images/. Keeping shared assets out of the locale key path saved one of our retail clients 22TB of edge storage and bumped their global hit ratio by 11 points.

Related Articles