Sitemap
Mastering Software Dev

Sharing valuable lessons and fresh insights from the world of software development — crafted just for you!

Changing UI Labels Without a Redeploy: A CDN Architecture for Localization

11 min read6 days ago

--

Photo by Fer Troulik on Unsplash

Adding a language to our web app meant a frontend release. Our app had about 4,000 UI labels spread across 34 namespaces, and every one of them was a JSON file in the frontend repo. When product asked for more languages, the translations weren’t the hard part. Everything around them was: new folders, a hardcoded language list, a new switcher option, and a release to ship it all.

This article walks through the localization architecture I proposed to take labels out of that release cycle. The idea is to author labels in a database, publish them as versioned files to a CDN, and let the running app pick them up on its next load. The client side uses i18next and react-i18next, but nothing in the design depends on a particular i18n library.

How far did it get? I built it as a proof of concept: an authoring portal, a translation service, CDN delivery, and a small demo app with about 50 labels in four namespaces and three languages. The 4,000-label app is the one I measured and costed; the proof of concept didn’t migrate it. I presented the design to the client and the engineering team together, both agreed to roll it out, and I handed it over to engineering to implement. It hasn’t shipped yet.

To keep the examples concrete, I’ll use a generic web app with four namespaces, common, dashboard, settings and reports, shipping in English and Spanish with Japanese planned next. One label shows up again and again: the Save changes button in settings.

Why does a wording fix need a release?

When labels live in the repo, the people who own the words and the people who own the deploy pipeline are different people, and the repo makes the first group wait for the second.

Picture a product writer who notices that the Spanish Save changes button says something slightly off. The text lives in src/locales/es/settings.json. The writer can't change it, so they raise a ticket. An engineer picks it up, edits one line, opens a pull request, and a reviewer approves a one-word diff. Then the fix waits for the next release window, which might be days away.

A new language follows the same path at a larger scale. Japanese needs a new locale folder, an entry in whatever list the app uses to know which languages exist, a new option in the language switcher, and a deploy. A business decision to enter a new market turns into engineering work before a single word is translated.

That gave me a simple requirement to design against. The people who own the words should be able to change them, and add a language, without anyone opening the frontend repo.

Three ways to ship translations

Most teams end up choosing between three shapes, and it helps to put them side by side.

Press enter or click to view image in full size

Building translations in is a sound choice when labels rarely change and only engineers touch them. Zip’s engineering team chose it on purpose and wrote up why; for their constraints it’s the right call. It just doesn’t change who owns the words.

The option most teams reach for next is an API: move the labels into a database, expose an endpoint, and fetch them when the app starts. That fixes ownership quickly. The cost shows up later, because every page load now waits on a backend call before it can render text, and the whole UI depends on that backend staying up.

Here’s what settled it for me. Once a translation is published, it doesn’t change until the next publish. It’s a static file. Running a function and a database query on every page load to hand back a file that hasn’t changed since Tuesday adds latency and one more thing that can fail. So the design keeps a database where people edit, and a CDN for everything users read.

Authoring and delivery are two different systems

On the authoring side, people do their work in an internal portal backed by a translation service and a relational database. That database is the single source of truth: it holds the namespaces, the keys, the value of each key in each language, the list of languages, and the history of every publish. Access is role-based, so anyone can see how complete each language is, but only admins can publish. (The frontend half of that permission check follows the approach in A Privilege Injection Pattern for React Apps)

On the delivery side there is object storage behind a CDN, and nothing else.

The two sides meet in exactly one place, the publisher. It’s a single API call that reads the database, compiles it into files, and uploads them.

Press enter or click to view image in full size

Authoring and delivery are two separate systems. The publisher is the only bridge, and the app reads only from the CDN.

There’s no arrow from the app back to the translation service. The app never calls it, so a deploy or an outage on the authoring side doesn’t stop a single label from loading.

Each publish produces one file per language. Inside it, every namespace is a key, and the labels within a namespace are flat:

{
"common": { "save": "Guardar", "cancel": "Cancelar" },
"dashboard": { "title": "Panel" },
"settings": { "saveButton": "Guardar cambios" }
}

With this shape, one request at startup gives a user every label in their language. Whether one file per language is the right size comes up again at the end.

One pointer, many immutable versions

A bundle is never overwritten. Each publish writes a new directory, and a small pointer file records which directory is current:

meta.json        <- the pointer
v41/en.json v41/es.json
v42/en.json v42/es.json
v43/en.json v43/es.json v43/ja.json

The pointer only needs a handful of fields:

{
"version": 43,
"publishedAt": "2026-03-04T12:00:00Z",
"supportedLanguageCodes": ["en", "es", "ja"],
"languages": [{ "code": "en", "name": "English" }, { "code": "es", "name": "Español" }, { "code": "ja", "name": "日本語" }]
}

The two kinds of file are cached in opposite ways:

meta.json      Cache-Control: public, max-age=60
v43/es.json Cache-Control: public, max-age=31536000, immutable

Front-end engineers will recognize this from build tools, which put a content hash in the file name of every JavaScript and CSS asset. Here the version number plays the same role. Because a bundle URL can never change its content, it’s safe to cache it for a year and mark it immutable, which tells the browser it doesn't even need to re-validate. When a Spanish fix to Save changes goes out as v43, the pointer moves, the app requests v43/es.json, and the browser treats that as a new URL. There's no cache-busting code anywhere in the client.

Get Sashika Suraweera’s stories in your inbox

Join Medium for free to get updates from this writer.

The “within a minute” behavior depends on two settings that are worth checking before you go live. On CloudFront, the cache policy’s minimum TTL has to be 60 seconds or less, and the pointer must not be served with stale-while-revalidate; otherwise edges and browsers can hold the old pointer for longer. The publisher also invalidates only meta.json and the newly written paths, never older versions. CloudFront includes the first 1,000 invalidation paths each month at no charge, which is far more than a team publishing a few times a day will use.

Press enter or click to view image in full size

The pointer names the current version. Publishing adds a folder and moves the pointer; rolling back moves it back.

Rollback needs almost no extra code. Say the v43 publish included a broken Japanese string. To roll back to v42, the publisher rewrites meta.json and invalidates it. The v42 files never left storage and are still cached at the edge, identical to what users loaded yesterday.

I’d check one thing in any code review of the publisher: the pointer has to carry the language list of the version it points at. If Japanese arrived in v43 and you roll back to v42, the switcher has to stop offering Japanese, because there’s no v42/ja.json to load.

My proof of concept gets this wrong. Its rollback rewrites the pointer with today’s active languages, so rolling back past the day Japanese was added would offer Japanese and then ask for a file that isn’t there. Nobody noticed, because we never tested a rollback across a language addition. The fix is a few lines: keep the language list with each published version and write that one back.

How does the app boot without flashing raw keys?

If the app renders before its translations arrive, users briefly see settings.saveButton written on the button. That flash is the most visible sign of a poorly wired i18n setup, and it's avoidable.

A small boot loader runs before the first render. It fetches the pointer, chooses a language by matching the browser’s locale against the supported list (falling back to the first entry), fetches the bundle for that language, and initializes i18next with the bundle passed in as resources.

// boot.ts: runs once, before React renders anything
export async function bootTranslations(): Promise<Meta> {
const meta = await getJson<Meta>(`${CDN}/meta.json`).catch(() => FALLBACK_META);
const lng = pickLanguage(navigator.language, meta.supportedLanguageCodes);
const bundle = await getJson<Bundle>(`${CDN}/v${meta.version}/${lng}.json`)
.catch(() => loadFallbackBundle());
await i18next.use(initReactI18next).init({
lng,
resources: { [lng]: bundle },
ns: Object.keys(bundle),
defaultNS: 'common',
interpolation: { escapeValue: false },
});
return meta;
}

I wrote this loader by hand instead of using the i18next-http-backend plugin. The plugin handles lazy loading well, but the version number and the bundle URL are precisely what this design needs to control, and a single async function is easier to follow than a plugin's lifecycle.

On the React side, the documented way to wait for a promise during rendering is use() inside a Suspense boundary, with the promise created once, outside of render:

const ready = bootTranslations(); // started at module load

function Root() {
const meta = use(ready);
return <AppShell meta={meta} />;
}
createRoot(el).render(
<Suspense fallback={<SettingUp />}>
<Root />
</Suspense>,
);
Press enter or click to view image in full size

The boot loader runs before the first render, so the Save button never shows its raw key.

On a first visit, users see a short spinner while two small requests complete. On later visits with the same version, the bundle comes from the browser’s disk cache and only the pointer goes over the network.

Changing language while the app is running follows the same logic. If the target bundle is already loaded, the switch is immediate. If it isn’t, a brief overlay covers the screen while the app fetches, for example, v43/es.json, and then i18next switches.

Three fallbacks, all in English

This design swaps a runtime dependency on an API for a runtime dependency on a CDN. That’s a far more reliable thing to depend on, but it’s still a dependency, and the boot loader needs a plan for the day it fails.

It falls back through three tiers, and every tier serves English:

Press enter or click to view image in full size

Each tier is tried only when the one above it fails, and every tier ends in English.

The first tier is a set of English JSON files served from the app’s own origin, so it doesn’t touch the CDN at all. The second is a fixed, known-good version on the CDN, a stable URL the browser has most likely cached already. The third is a minimal English bundle compiled into the JavaScript, which means even a first visit with no translation network at all still renders something a user can work with.

Spanish and Japanese are intentionally absent from every fallback. Shipping every language inside the app would bring back the exact coupling this design exists to remove.

The same reasoning is why the pointer also comes from the CDN. The translation service can be unavailable during a deploy, a cold start or a throttling incident, and the app still starts with the most recently published version. An API version of the pointer exists, but only for local development, where there is no CDN.

Staleness, and languages as data

Nothing in the CDN setup knows whether a Spanish label is still right. That knowledge has to live in the data model.

The first mechanism is staleness. A Spanish label is only correct relative to the English text it was translated from. So when a writer changes the English “Save Changes” to “Save Settings”, the translation service marks the Spanish and Japanese values for that key as stale, and the portal shows those cells in different color for example amber. Saving a new Spanish value clears the flag for Spanish only.

The second is treating languages as data. Adding Japanese means inserting a row into the language table. The service then creates an empty value for every existing key, the portal shows Japanese at 0 percent, translators fill it in, and the next publish adds ja to the pointer. The app builds its language switcher from the pointer, so Japanese appears as an option without any code change.

The next planned step connects a third party translation service vendor to those same flags. The service collects every key that is missing or stale, estimates the cost from the vendor’s per-character rate, places an order, and receives the finished translations by webhook, with polling as a fallback.

Where this design stops paying off

These are the limits I would raise in any review of it. A browser tab that stays open keeps its labels until the next reload, because nothing pushes a new version into a running session. A single bundle per language means a user who only ever opens the dashboard still downloads the labels for every settings screen. Storage grows slightly with every publish, since old versions are kept on purpose.

For some teams it’s simply the wrong design. If your labels change a few times a year, only engineers edit them, and no new language is on the roadmap, building translations into the app is simpler and has fewer moving parts. The pointer, the portal and the fallback chain earn their cost only when people outside engineering own the words, or when new markets are on the way and going to onboard new clients from different regions all over the world.

If you do build it, two decisions are much cheaper to make early than late. Split the bundles by namespace once the app grows past a size you’re comfortable loading at startup, keeping common in the first request and loading the rest on demand, with the same version in path layout. And give every key a context field from the first day as it is very helpful when integrating this to third party translation services and when need to communicate what we need. Because language like Japanese and some languages words/phrase matter most when conveying the idea. So your word is not just a translated word of English word/phrase.

--

--