Skip to content
hemju.

Localization Is a Cache Invalidation Problem, Not a Translation Problem

At production scale, localization breaks as stale reads across caches, not bad translations. Treat locale strings as versioned distributed state, not static assets.

Localization Is a Cache Invalidation Problem, Not a Translation Problem

Most teams consider localization done the moment i18next resolves a key to the right string in three locales. The demo works. QA signs off. Then a marketing team ships a pricing copy change, and for the next forty minutes some fraction of German users see the old price while everyone else sees the new one — depending on which CDN edge they hit, which pod served the SSR request, and whether their browser had a warm bundle. That’s not a translation bug. Nobody mistranslated anything. It’s a distributed cache serving inconsistent versions of the same mutable data.

The framing that i18n is a UI concern is where this goes wrong. Once translated content lives in more than one place at once — and at any real scale it always does: build artifacts, CDN edges, service-local caches, browser storage — you have a distributed cache with no invalidation contract. You wouldn’t ship a Redis layer without thinking about staleness, coherence, and versioning. But teams ship translation content with exactly that gap because a library made the retrieval look trivial.

The moment translations stop being static assets

There are two localization architectures and most teams don’t realize they’ve silently migrated from one to the other.

In the first, translation strings are compiled into the build. They ship as part of the JS bundle or a static JSON blob, cache-busted by content hash, and a copy change requires a deploy. This is genuinely a UI concern. It’s also coherent by construction: the hash in the asset URL is the version, invalidation is a new deploy, and there’s no window where two versions coexist within a single release because the release is atomic. If you can live with copy changes going through CI, stop here. This is the calm option and I’ll defend it below.

The second architecture is where the trouble starts: translations become runtime data. A translation management system (TMS) — like LingoHub, my own product, or a homegrown table — becomes the source of truth. Marketing edits copy without a deploy. Strings are fetched at runtime, cached at the edge, cached in each service, cached in the client. The instant you make that move to let non-engineers ship copy without CI — which is the entire selling point of a TMS — you’ve converted static assets into mutable, versioned, distributed state. The library didn’t change. The consistency model did. And almost nobody writes down the new contract.

The failure isn’t translation quality. It’s that “the current German copy” now has no single answer at any given instant. It has as many answers as you have cache tiers, and nothing forces them to agree.

Where the stale reads actually come from

Concretely, the stale translation bugs that show up in production cluster around a few mechanisms.

TTL soup across tiers. Your CDN caches the translation payload for, say, 300 seconds. Your BFF caches its parsed copy for 60. The client holds a version in memory until reload and in localStorage indefinitely. These TTLs were chosen independently, by different people, at different times. When content updates, the window during which a user can observe an inconsistent mix is the sum and interleaving of these layers, not the max. A user can load a page whose SSR HTML came from a pod with fresh copy while the client-side hydration bundle pulls stale strings from a warm edge — and you get a hydration mismatch that React papers over silently or, worse, throws on.

Per-key, not per-bundle, updates. TMS webhooks often fire per changed key. Teams wire an invalidation that busts only the changed keys’ cache entries. But copy changes rarely arrive alone — a pricing change touches the price, the CTA, the fine print, and the tooltip, edited over several minutes. If invalidation is granular and eventually consistent, users see the new CTA against the old price. Partial invalidation of a logically atomic content change is worse than no invalidation, because a fully stale page is at least internally consistent.

Locale drift across services. In a microservice system, the checkout service, the email service, and the web frontend each fetch translations independently, often through different client libraries with different cache defaults. A user gets an order-confirmation email rendered from copy that’s two versions behind the web page they just saw, because the email worker’s cache refreshes on a cron and the web tier refreshes on a webhook. Nobody owns cross-service locale consistency because localization was scoped as a frontend ticket.

Fallback masking. i18next’s missing-key fallback is a reliability feature that hides correctness bugs. When a locale bundle fails to load or a key is absent in the fetched version, the library silently serves the fallback locale or the key itself. In a distributed setup, “German bundle at this edge is stale and missing the new key” degrades to English text on a German page, and your error rate stays flat. You find out from a support ticket, not a dashboard.

Translation content needs a version, not a timestamp

The fix starts by treating a set of translations the way you’d treat any versioned config: as an immutable, addressable snapshot, not a mutable bag of keys refreshed on TTL.

Give every published state of your translation content a monotonic version — a content hash over the full locale set, or a release ID from the TMS. The unit of publication is the snapshot, never the individual key. When marketing finishes editing the pricing block, that becomes version N+1 as one atomic transition, the same way you’d cut a config release. Granular key edits happen in the drafting stage; they never reach production as independent invalidations.

Then make the version part of the address. Instead of every tier fetching /translations/de.json and racing on TTLs, they fetch /translations/de/{version}.json. Immutable content, immutable URL, Cache-Control: immutable, max-age=31536000. Now the CDN, the BFF, and the browser can cache aggressively and correctly, because a given URL never changes meaning. This is the same trick as content-hashed JS bundles, applied to runtime translation data — and it dissolves most of the TTL-soup problem, because there’s nothing to invalidate. Old versions simply stop being requested.

The only thing that changes at runtime is a small pointer: “the current version for de is N+1.” That pointer is the one piece of mutable state, and because it’s tiny, you can propagate it deliberately — short TTL, or push via your existing config channel — instead of eventually-consistent guessing across large payloads. You’ve reduced the consistency problem from “keep megabytes of copy coherent across every tier” to “propagate one integer.” That’s a solvable problem. The megabyte version is not.

This is what I mean by translation content versioning as an architecture decision. It’s not about which library resolves the key. It’s about whether the content behind the key is addressable and immutable.

Pin the version per request, or accept the mismatch

Immutable snapshots fix coherence within a tier. They don’t automatically fix coherence within a single user’s request path, which is where hydration mismatches and cross-service drift live.

The rule that closes this gap: resolve the active translation version once, at the entry point of a request, and thread it through everything downstream. When a request hits your BFF or SSR layer, it reads the current pointer, pins version N, and passes N explicitly to every service call and into the client bootstrap. The client hydrates against N. The checkout call carries N. If the order-confirmation email is queued from that request, it carries N. Every surface the user touches in that logical interaction renders from the same snapshot, even if the global pointer advances to N+1 mid-session.

This is read-your-writes consistency scoped to a user session, and it’s the same discipline you’d apply to any distributed cache where a user must not see their own state go backwards. Without it, you can build perfect per-tier immutability and still ship the new-CTA-old-price mismatch, because the frontend pinned N+1 and the checkout service independently resolved N.

The counterargument here is real and worth stating plainly: this is more machinery than most products need. Threading a version ID through every service boundary is invasive, and if your copy changes weekly and a few minutes of skew is harmless, it’s over-engineering. Fair. The threshold I use: version pinning earns its cost when copy is legally or financially load-bearing — prices, terms, consent language, regulated disclosures — or when a mismatch produces a hard error like a hydration crash rather than cosmetic drift. For a marketing tooltip, TTL soup is an acceptable risk. For a checkout price, a user seeing two numbers in one flow is a support and trust incident, and possibly worse depending on jurisdiction.

The CDN is where localization consistency is won or lost

A CDN localization strategy is usually an afterthought — “we’ll cache the translation JSON at the edge” — and it’s the tier that most quietly breaks consistency, because edge caches are numerous, geographically distributed, and invalidate on their own schedules.

Two concrete decisions matter.

First: never let a single URL vary its content by locale via Vary headers or edge logic and also cache it. If /api/copy returns German or English depending on Accept-Language and sits behind a shared cache, you will eventually serve German copy to an English user from a poisoned edge entry — a cache key collision, not a translation bug. Put the locale in the path (/translations/de/N.json), not in a header the cache has to reason about. Path-based keys make the cache correct by construction; Vary-based keys make it a bet on every CDN’s header-normalization behavior.

Second: decide explicitly whether the CDN caches the version pointer at all. If it does, your global copy-update latency is bounded below by that TTL, and a purge is required for anything faster. CDN purges are not instantaneous and not globally atomic — propagation across edges takes seconds to minutes and can partially fail. If your model depends on a purge landing everywhere at once to switch versions, you’ve built a race. The immutable-snapshot approach sidesteps this: you never purge content, you only move a pointer, and old versions age out naturally. Reserve purges for the emergency case — a legally wrong string that must die now — and treat that as the exceptional, slow, best-effort path it actually is.

What “solved” actually looks like

The honest version of “localization is solved” is not “we wired up i18next.” It’s: translation content has an immutable, versioned representation; the active version is a single small pointer with a deliberate propagation path; a request pins one version end to end; the CDN keys on locale-in-path, not headers; and missing-key fallbacks are instrumented so silent degradation shows up on a dashboard instead of in a ticket.

Most teams don’t need all of this, and I’d push back hard on adding it speculatively — if you compile translations into content-hashed build artifacts and ship copy through CI, you already have every one of these properties for free, and that’s the architecture I’d default to. The moment you can’t live with copy-through-CI and reach for a runtime TMS, you’ve accepted a distributed cache, and you owe it the same versioning, invalidation contract, and consistency guarantees you’d give any other one.

The translation was never the hard part. Coordinating many copies of mutable data across tiers, services, and edges — under real traffic, while it changes — is the hard part. Name it as a cache problem and the failure modes stop being surprises.

References

  • Fowler, Martin. “Patterns of Distributed Systems.” martinfowler.com/articles/patterns-of-distributed-systems
  • MDN Web Docs. “HTTP caching” and “Vary.” developer.mozilla.org/en-US/docs/Web/HTTP/Caching
  • i18next documentation, “Configuration Options” (fallback and missing-key behavior). i18next.com/overview/configuration-options
  • Nygard, Michael T. Release It!: Design and Deploy Production-Ready Software, 2nd ed., Pragmatic Bookshelf.
  • Fastly Documentation. “Purging” (propagation and best-effort semantics). docs.fastly.com/en/guides/purging-overview