Static Translation Bundles with CDN Failover Explained

By the ResilientLang team · Published · 6 min read

A static translation bundle is a pre-built file of translated strings that you serve like any other asset, so it keeps working as long as one of your hosts does. Combined with immutable versioned file names, a manifest that lists the current versions, a second CDN as backup, browser-side caching and a fallback to the source language, it removes the need for a live call to a translation service while a visitor loads the page. This article explains each piece and shows small code sketches you can adapt.

What is a pre-built translation bundle?

Many translation setups translate on demand: a script or server asks a service for text each time. A bundle approach moves that work to build or publish time. You export the strings for each language once, write them to files such as fr.3f9a1c.json, and publish them next to your other static files.

At runtime the page only fetches a small file. There is no translation computation and no account lookup in the path. If the file is on your own origin, it also fails exactly when your site fails, not separately.

The trade-offs are real. Bundles go stale if content changes and nobody rebuilds them, and dynamic content (user-generated text, live prices) needs another mechanism. Bundles describe the translated strings; whether those strings end up in the HTML that search engines index is a different question covered in client-side vs server-side translation and SEO.

Why use immutable, versioned file names?

When a URL's content never changes, caching becomes simple and safe. Put a content hash in the file name, and a new version gets a new URL. MDN describes the immutable directive as telling caches that a fresh response will not change, so they skip revalidation even when the user reloads. It is aimed at cache-busted static assets whose URLs change when their content does.

# /i18n/fr.3f9a1c.json
Cache-Control: public, max-age=31536000, immutable

31536000 seconds is one year, a common ceiling for fingerprinted files. Google's own JavaScript guidance also recommends content fingerprinting in file names, because its rendering service can use stale JavaScript or CSS when it caches aggressively.

The file that cannot be immutable is the one that says which version is current. That is the manifest.

What does a manifest do?

A manifest is a small file that maps each language to its current bundle:

{
  "version": "2026-10-28T09:00:00Z",
  "sourceLanguage": "en",
  "bundles": {
    "fr": "/i18n/fr.3f9a1c.json",
    "de": "/i18n/de.77b0de.json"
  }
}

Because its content changes with every release, serve it with a short lifetime and a stale allowance:

# /i18n/manifest.json
Cache-Control: public, max-age=300, stale-while-revalidate=86400, stale-if-error=604800

The numbers here are illustrative, not recommendations. Choose them based on how quickly a translation change must appear and how long you can tolerate an old one.

How do stale-while-revalidate and stale-if-error work?

Both directives are defined in RFC 5861, which is an Informational RFC, not a Standards Track document.

  • stale-while-revalidate=N: after the response becomes stale, a cache may keep serving it for up to N seconds while it revalidates in the background, so the visitor does not wait.
  • stale-if-error=N: if an error occurs when the cache tries to get a fresh copy, it may serve the stale response for up to N seconds past its freshness. RFC 5861 counts 500, 502, 503 and 504 as errors.

Two cautions. First, RFC 9111, the current HTTP caching specification, does not define either directive. It only mentions RFC 5861 as an example of extensions that can permit serving stale responses, and it requires caches to ignore directives they do not recognise. Second, MDN documents the response directives but its page excerpt does not show browser support per directive, so check the compatibility table and your CDN's documentation. In practice this means these headers are a useful optimisation and a safety net, not a guarantee. Your own code should still handle failure, which the next section does.

How does multi-CDN failover work in the browser?

The browser-side loader tries a list of base URLs in order, gives each a short timeout, and finally falls back to the source language. Here is a minimal sketch:

const BASES = [
  "/i18n",                       // your own origin first
  "https://cdn-a.example.com/i18n",
  "https://cdn-b.example.com/i18n",
];

async function fetchWithTimeout(url, ms) {
  const ctl = new AbortController();
  const t = setTimeout(() => ctl.abort(), ms);
  try {
    const res = await fetch(url, { signal: ctl.signal });
    if (!res.ok) throw new Error("HTTP " + res.status);
    return await res.json();
  } finally {
    clearTimeout(t);
  }
}

async function loadBundle(lang) {
  for (const base of BASES) {
    try {
      const manifest = await fetchWithTimeout(base + "/manifest.json", 2000);
      const file = manifest.bundles[lang];
      if (!file) return null;               // language not offered
      const name = file.split("/").pop();
      return await fetchWithTimeout(base + "/" + name, 2000);
    } catch (e) {
      // try the next base
    }
  }
  return null; // caller keeps the source-language text
}

loadBundle(document.documentElement.lang).then((strings) => {
  if (strings) applyTranslations(strings); // otherwise leave the page as is
});

Details that matter:

  • The page must already contain readable source-language content, so null means "do nothing", not "show an error".
  • Timeouts need to be short. A hanging request is worse than a failed one.
  • Load the script with async or defer, so a slow script never blocks first paint.
  • Using the same relative file name on every host assumes your CDNs mirror one another, which is a deployment requirement you must enforce.

Where do service workers and localStorage fit in?

A service worker can answer requests from the Cache API when the network fails. MDN's guide shows the pattern: check the cache, try the network, store a clone of the response, and return a fallback Response if both fail. Service workers run only on HTTPS (with localhost treated as secure for development).

// sw.js (sketch)
self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);
  if (!url.pathname.startsWith("/i18n/")) return;
  event.respondWith((async () => {
    const cache = await caches.open("i18n-v1");
    const cached = await cache.match(event.request);
    const network = fetch(event.request).then((res) => {
      if (res.ok) cache.put(event.request, res.clone());
      return res;
    });
    return cached || network.catch(() => new Response("{}", {
      status: 503, headers: { "Content-Type": "application/json" }
    }));
  })());
});

This is a stale-while-revalidate pattern implemented in code: serve the cached copy, refresh in the background. For simple needs, localStorage can hold the last good bundle as a string, but it is synchronous, small and unavailable in some private modes, so wrap reads and writes in try/catch.

None of this helps search engines. Google's documentation states that its rendering service does not retain local storage, session storage or cookies across page loads, so browser caches are a visitor feature, not a crawler feature. For Googlebot, the translated text must be in the delivered HTML or fetched successfully at render time.

How do you fall back to the source language safely?

Define the fallback rule once and make it explicit:

  1. The HTML is authored or generated with source-language text in place.
  2. If a bundle loads, replace strings by key.
  3. If a key is missing, keep the source text for that key, not an empty string.
  4. If no bundle loads, keep everything as is and leave the language switcher working.

Then test it by breaking things on purpose. How to test translation failure gives a repeatable method, and what happens when your translation provider goes down lists the failure modes you are protecting against. Our own product, ResilientLang, is designed around this model, but it is not released.

Key takeaways

  • A bundle is a static file, so it can be hosted on your origin and two CDNs rather than depending on a live translation call.
  • Hash file names and mark them immutable; keep the manifest short-lived with stale allowances.
  • stale-while-revalidate and stale-if-error come from RFC 5861 (Informational) and are not defined in RFC 9111; test support rather than assuming it.
  • Failover logic needs short timeouts, an ordered list of hosts, and a source-language default.
  • Browser-side caching does not help Googlebot, so decide separately how translated content reaches the indexed HTML.

Sources

Frequently asked questions

What is a static translation bundle?

A static translation bundle is a pre-built file, usually JSON, holding the translated strings for one language and one version of a site. Because it is generated ahead of time rather than on each request, it can be served like any other static asset from your own server or a CDN.

Why use immutable, versioned file names for translations?

If a file's name changes whenever its content changes, the old URL can be cached for a very long time without risk of serving wrong content. The Cache-Control immutable directive tells browsers they need not revalidate such files, which removes repeat network requests.

Do stale-while-revalidate and stale-if-error work in every cache?

No. Both come from RFC 5861, an Informational RFC, and RFC 9111 says caches must ignore directives they do not recognise. Support varies by browser and CDN, so test it. Do not rely on these headers as your only fallback.

Is client-side failover enough for SEO?

Not by itself. Google does not keep local storage or cookies between renders, so a browser cache cannot help Googlebot. For search, the translated content should be present in the HTML that is served, which is a separate design question from runtime failover.

Keep reading