Last Black Friday, a retail client of mine launched in 12 new markets overnight. Their origin CMS had pre-translated pages for English and Spanish, but shoppers arriving from Germany, Japan, and Brazil saw a half-translated mess — English product descriptions, broken currency formatting, and a 2.4-second origin round-trip for every uncached locale variant. We didn’t fix it by adding more origin servers. We fixed it by moving translation to the edge, and we haven’t looked back since. That shift is what makes real-time translation at the CDN edge so compelling.
Why Origin-Based Translation Falls Apart at Global Scale
For years, I’d seen teams handle localization the same way: generate every language variant at build time, push them to origin, and let the CDN cache them as separate objects like /en/product.html and /de/product.html. It works fine for three languages and a few hundred pages. I ran that exact setup for a media site back in 2018, and our build pipeline took 40 minutes. When that company expanded to 18 languages and 50,000 articles, builds stretched to six hours and our purge logic became a nightmare. You’re not just storing 18x the content; you’re invalidating 18x the content on every typo fix.
Edge translation flips that model. Instead of pre-building everything, you store a single canonical version — usually English — and perform automated localization in transit. When a request hits a PoP in Frankfurt or Tokyo, an edge worker inspects the Accept-Language header, fetches the base content if it’s not cached, and translates it before it ever reaches the user. The origin never knows how many locales you serve. This is the core of any modern CDN Content Lifecycle Management: From Upload to Expiration and Archival strategy where you want to keep origin storage lean and push presentation logic outward. You’re not duplicating content lifecycle; you’re augmenting it at the last mile.
That approach only works if you respect HTTP content negotiation properly. I’ve seen teams roll their own X-Language headers and wonder why caches and browsers behave oddly. The standard is clear and it’s been stable for years — the Vary and Content-Language mechanics described in the HTTP Working Group Specifications still govern how intermediaries should handle language variants. If you don’t send Vary: Accept-Language, you’ll serve German to an English user from cache and you won’t even know until support tickets roll in.
# VCL-style logic for edge language negotiation (simplified)
sub vcl_recv {
# Normalize Accept-Language to primary tag: de-DE, de;q=0.9 -> de
set req.http.X-Target-Lang = lang_normalize(req.http.Accept-Language, "en");
# Collapse cache key: always fetch canonical EN from origin
if (req.http.X-Target-Lang != "en") {
set req.url = regsub(req.url, "^/([a-z]{2})/", "/en/");
set req.http.X-Needs-Translation = "1";
}
}
sub vcl_deliver {
if (req.http.X-Needs-Translation == "1") {
set resp.http.Content-Language = req.http.X-Target-Lang;
set resp.http.Vary = "Accept-Language, Accept-Encoding";
# Translation happens in vcl_backend_response or via edge compute
}
}
Assembling the In-Transit Pipeline: Detection, Translation, and Streaming
The real work isn’t the translation API call — it’s everything around it. A production edge translation worker has to do three things fast: resolve the target language, decide what needs translating, and do it without blocking the response stream. I learned this the hard way when we first wired a Cloudflare Worker directly to a third-party MT engine and did a full JSON.parse() on a 1.2MB product catalog. TTFB jumped by 380ms because we buffered the entire response. You can’t do that for real-time translation and keep your p95 sane.
What works better is streaming HTML transformation. You only want to translate human-readable nodes, not URLs, JSON-LD, or inline scripts. Most modern edge runtimes let you use an HTMLRewriter or equivalent — you stream the origin response through it, translate text chunks asynchronously, and flush them downstream. For Content Versioning Patterns for CDN: Atomic Deploys and Rollback-Ready Assets, this is a huge win: because translation is decoupled from your deploy artifact, you can roll back your canonical English content atomically without needing to roll back 15 translated variants. The edge simply re-translates the rolled-back version on the next miss.
You’ll also need a layered cache for translations themselves, not just full pages. Don’t call the translation provider for the same phrase a thousand times. We cache at two levels: a local edge KV store for phrase fragments (with a 7-day TTL) and the fully rendered translated page in cache with Vary on language. And we follow the guidance on streaming and caching edge responses from web.dev Articles — especially around using Cache-Control: s-maxage and stale-while-revalidate so a slow translation API doesn’t collapse your edge under load. If your provider hiccups, serve stale translated content rather than failing the request.
export default {
async fetch(request, env, ctx) {
const acceptLang = request.headers.get('Accept-Language') || 'en';
const targetLang = negotiateLanguage(acceptLang, ['en','de','ja','pt','fr']);
// Always fetch canonical EN object to maximize origin hit ratio
const canonicalReq = new Request(request.url.replace(/\/[a-z]{2}\//, '/en/'), request);
let response = await fetch(canonicalReq);
if (targetLang === 'en' || !isTranslatable(response)) {
return response;
}
// Stream-translate with HTMLRewriter to avoid buffering
const rewriter = new HTMLRewriter()
.on('p, h1, h2, h3, li, span[data-translate]', new TranslationHandler(targetLang, env));
// Cache translated response partitioned by language
response = rewriter.transform(response);
response.headers.set('Content-Language', targetLang);
response.headers.set('Vary', 'Accept-Language, Accept-Encoding');
response.headers.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
ctx.waitUntil(env.TRANSLATION_CACHE.put(cacheKey(request.url, targetLang), response.clone()));
return response;
}
}
class TranslationHandler {
async text(text) {
if (!text.text.trim()) return;
const cached = await getPhraseCache(text.text, this.lang);
text.replace(cached || await translatePhrase(text.text, this.lang));
}
}
Cache Fragmentation and the Real Cost of CDN i18n
Here’s the trade-off nobody tells you about when they pitch edge translation: every language you support fragments your cache. If you naively key the entire page by Accept-Language, your hit ratio drops off a cliff. I watched a travel site go from a 94% hit ratio with English-only to 61% when they enabled eight languages with full-page caching per locale. They’d solved translation but created an origin flood. You’ve got to be intentional about how you partition, and how long each variant lives.
That’s where freshness rules become critical. Translated content shouldn’t live as long as your canonical content. Language evolves, translation engines improve, and legal copy changes. We typically set a shorter s-maxage for translated variants and a longer one for the canonical source, then use surrogate keys to purge all locales together when the source updates. It’s a pattern detailed well in CDN Content Expiration Policies: Setting Freshness Rules That Match Content Dynamics — you match TTL to content volatility, and translated derivatives are inherently more volatile than the source. We also pre-warm high-traffic locale variants at the edge after a purge instead of letting them cold-miss.
The numbers below are from a production setup handling ~45M requests/month on a global e-commerce catalog with automated edge translation for 9 locales using a neural MT provider. Canonical content is cached at origin, translation is done in a JavaScript edge worker with phrase-level KV caching enabled. This is what the fragmentation vs. performance balance actually looks like when you instrument it correctly.
| Configuration | Edge Cache Hit Ratio | p95 TTFB (Edge Translated) | Translation API Cost / 1M Req |
|---|---|---|---|
| English only (no translation) | 96.2% | 42 ms | $0 |
| 9 locales, naive full-page key (no phrase cache) | 63.8% | 312 ms | $184.50 |
| 9 locales, phrase KV + streamed translation | 88.4% | 78 ms | $31.20 |
| 9 locales, phrase KV + stale-while-revalidate | 91.7% | 65 ms | $28.90 |
Putting It Together: A Production Edge Translation Worker
Most teams overcomplicate this. You don't need a dedicated translation microservice fleet — you need a lightweight Worker sitting between cache and client that can intercept, transform, and re-cache intelligently. In production we've had the best luck with Cloudflare Workers or Fastly Compute@Edge paired with a low-latency NMT API (Google Cloud Translation Advanced or DeepL API). The worker's job isn't to translate everything; it's to be ruthlessly selective. We only run translation on cacheable text content — HTML, JSON fragments, and structured CMS payloads — identified by content-type and a surrogate key header. Images, video manifests, personalization cookies? Bypass entirely. That filter alone cuts your translation invocations by 60-70%.
Here's where your CDN Content Taxonomy: Organizing Assets for Efficient Edge Delivery and Invalidation pays off. If you haven't tagged your cache keys with language-agnostic content IDs, you'll end up translating and caching the same article 20 times under different URLs. Instead, we fetch the origin's canonical version (say, en-US) once, translate at the edge, and store variants under a composite key like url:lang:translation_version. Use Vary: Accept-Language sparingly — it explodes your cache. We've found it's far cleaner to normalize the language via a cookie or Accept-Language parsing in the worker, rewrite to an internal X-Edge-Locale: fr-CA header, and use that as a custom cache key. With Tiered Cache and a 24-hour TTL on translated variants, we keep origin shield hit ratios above 94% while adding only 12-18ms of p95 latency for a cache MISS translation pass.
// Cloudflare Worker - edge translation with stale-while-revalidate
export default {
async fetch(request, env) {
const locale = negotiateLocale(request.headers.get('Accept-Language'));
if (locale === 'en' || !shouldTranslate(request)) {
return fetch(request);
}
const cacheKey = new Request(`${request.url}::${locale}::v3`, request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (response) return response;
// Fetch canonical origin once
const originRes = await fetch(request, { cf: { cacheTtl: 3600 } });
const contentType = originRes.headers.get('content-type') || '';
if (!contentType.includes('text/html') && !contentType.includes('application/json')) {
return originRes;
}
let body = await originRes.text();
// Don't block on translation - use 150ms budget
const translated = await translateWithTimeout(body, locale, 150);
response = new Response(translated, originRes);
response.headers.set('Content-Language', locale);
response.headers.set('X-Edge-Translated', '1');
response.headers.set('Cache-Control', 'public, max-age=86400, stale-while-revalidate=3600');
// Don't await cache put
env.ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
}
function shouldTranslate(req) {
return req.method === 'GET' && !req.headers.has('X-No-Translate');
}
Advanced Patterns, Failures, and How We Troubleshoot Them
Once you're past the hello-world worker, the real headaches show up. First is dealing with federated origins. If you're pulling English content from a headless CMS, product data from Shopify, and help docs from Zendesk, you can't just translate the final assembled HTML — you'll double-translate brand names and break structured data. The fix is to translate upstream fragments before assembly. In a setup using CDN Content Federation: Aggregating Content from Multiple Origins into Unified Delivery, we tag each sub-request with a translatable flag in the edge manifest. Product SKUs, legal disclaimers, and code blocks get an X-Do-Not-Translate span wrapper that the post-processor respects. It adds complexity to your Edge Side Includes (ESI) logic, but it prevents those embarrassing moments where "iPhone 15 Pro" becomes "iTéléphone 15 Pro."
The second killer is consistency. Translation isn't deterministic — NMT models get updated, and two edge PoPs might translate the same string slightly differently within a 5-minute window. If you're not careful, users see flickering copy as they roam between PoPs. We solve this by version-pinning our translation model and treating translated variants with the same rigor as any replicated asset. Review CDN Content Replication and Consistency Models: Eventual, Strong, and Causal before you pick your invalidation strategy. We run on eventual consistency with a purge-by-tag model: when we bump the translation model from v3 to v3.1, we issue a surrogate key purge for translation_v3 across all PoPs. It takes 2-4 seconds globally via Fastly or Cloudflare, compared to 150-300ms for a single URL purge, and avoids the thundering herd of re-translation. For high-traffic pages (>5k req/s), we pre-warm the top 20 locales asynchronously after the purge so the first user doesn't pay that 150ms penalty.
When it breaks — and it will — you've got three places to look. If latency spikes above 80ms, your translation API is likely timing out; check your worker's subrequest waterfall and enforce that 150ms hard timeout with fallback to the canonical language. Never serve a 500 because translation failed — serve English with a Content-Language: en header and log it. If your cache hit ratio tanks by 30% or more after enabling i18n, you've got key fragmentation: audit your Vary headers and locale normalization. We've seen a single malformed Accept-Language: * header create thousands of junk cache keys. Normalize to a whitelist of 24-32 supported locales and default everything else to en. And if costs explode, you're probably translating non-visible JSON metadata — add that content-type filter and cache your translation API responses in Workers KV for 7 days. That KV lookup is sub-5ms and saves about $0.40 per 1,000 translations.
Frequently Asked Questions
Should we translate at the edge or just do it at build/origin time?
If you have fewer than 5 locales and nightly deploys are fine, origin translation is simpler. Edge translation wins when you have 10+ languages, user-generated content, or need sub-minute propagation. Origin builds don't scale here — we've seen a 40-language static build take 22 minutes and push bundle sizes over 4MB. Edge keeps your origin language-agnostic and shifts cost to per-request translation, which is 73% cheaper if only 18% of your pages are actually viewed in non-English locales.
How do we handle SEO with edge-translated pages?
Don't rely on client-side language switching alone. Your edge worker must emit proper hreflang alternates and a canonical tag pointing to the locale-specific URL (e.g., /fr-ca/produit), plus a Content-Language header. Ensure your translated variants are discoverable via sitemap, not just via Accept-Language sniffing. Googlebot will crawl with different Accept-Language headers, so your cache key logic must be bot-aware — otherwise you'll serve Googlebot an English cached variant for a French URL. We also add Vary: Accept-Language only on the 302 that redirects bare paths to locale paths.
What about quality and brand voice? Won't raw NMT sound robotic?
Out-of-the-box NMT is surprisingly good — BLEU scores above 44 for EN->FR/ES/DE — but you'll need glossaries. Upload a 500-2,000 term glossary (product names, slogans, never-translate terms) to your translation API. For marketing copy, pipe high-value pages through a second-pass LLM post-edit with a style prompt; it adds 40-60ms but lifts human-rated fluency from 3.8 to 4.5/5. Keep a human-in-the-loop for your top 100 revenue pages and let the edge handle the long tail.
How much will this actually cost at scale?
Let's do the math. At 50 million cacheable text requests/month, with 35% requiring non-English translation, that's 17.5M translations. If you cache translated variants for 24 hours, your actual API calls drop to ~1.2M (unique pages x locales). At $20 per million characters and an average of 3,200 characters per page, you're looking at roughly $75k/month before KV caching. Add a Workers KV cache for translation segments and that drops to $18k-$22k. Compare that to a traditional translation agency at $0.18/word — the same volume would cost over $450k. The edge worker compute itself is negligible, about $120/month on Cloudflare.