Problem RestatementProblem
Adobe asked: design a localization system for a product released in dozens of countries. Every user-facing string must resolve by locale at runtime, support variables ("Hello, {name}"), follow language-specific plural rules ("1 file" / "2 files", and languages with more plural forms like Polish or Arabic), fall back safely when a translation is missing, and allow updating translations without redeploying the app.
Core Concepts
- Message keys: code never contains English text directly. It uses keys:
t("files.deleted", { count: 3 }). - Catalogs: per locale, a map
key → message:
en: files.deleted = "{count, plural, one {# file deleted} other {# files deleted}}"
pl: files.deleted = "{count, plural, one {Usunięto # plik} few {Usunięto # pliki} many {Usunięto # plików} other {Usunięto # pliku}}"
These use ICU MessageFormat, the standard syntax for variables, plurals, gender and selects. The plural categories (one, few, many, other) come from CLDR data per language.
- Formatting: dates, numbers and currencies use locale-aware formatters (
Intl.DateTimeFormat,Intl.NumberFormat), never string concatenation.
Deep Dive — When a string has no translationDeep dive
A new feature ships in English. A user in Quebec opens it before the French translation lands. What they see is a product decision, not an accident.
Show whatever the lookup returns
Look up the key in the user's locale and render the result.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
K["t('checkout.confirm') in fr-CA"] --> MISS["Key not translated yet"]
MISS --> EMPTY["Returns empty string"]
EMPTY --> BLANK["A button with no label"]
BLANK --> STUCK["The user cannot tell what it does - the flow is unusable"]A missing translation becomes a blank control. This is worse than showing English: the user is not inconvenienced, they are blocked, and nothing anywhere records that it happened.
Fall back to the default locale
If the key is missing, use English.
The interface stays usable, which is the important fix. Two gaps remain. fr-CA falls straight to English even when a perfectly good fr translation exists — so a Quebec user sees English for a string France already has. And the fallback is silent, so nobody learns which keys are missing.
A fallback chain, and treat every fallback as a signal
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
REQ["t('checkout.confirm') for fr-CA"] --> L1{"fr-CA?"}
L1 -->|"hit"| OUT["Render"]
L1 -->|"miss"| L2{"fr?"}
L2 -->|"hit"| OUT
L2 -->|"miss"| L3{"en - default"}
L3 -->|"hit"| OUT
L3 -->|"miss"| KEY["Render the key itself - never a blank"]
L1 -->|"miss"| LOG["Log the missing key + locale"]
L2 -->|"miss"| LOG
LOG --> DASH["Missing-translation dashboard - feeds the translation queue"]- Narrow to broad, then default:
fr-CA → fr → en. Regional variants differ in a minority of strings, so falling back to the base language gives a far better experience than jumping to English. - Never render nothing. If even the default is missing — which means a bug, not a translation gap — show the key. An untranslated label is a nuisance; an empty one is a broken interface.
- Log every fallback with the key and locale. This turns missing translations from something discovered by a user complaint into a queue the localisation team works from, and it is nearly free to add.
Two things that belong in the same conversation: never concatenate translated fragments — word order differs between languages, so the whole sentence must be one key with placeholders — and use the platform's plural and gender rules rather than an if count == 1, because many languages have more than two plural forms.
ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
DEV["Developers - add keys in code"] --> EXT["Extraction in CI - new/changed keys"]
EXT --> TMS["Translation management system"]
TMS --> TR["Translators / vendors + review"]
TR --> TMS
TMS -->|"publish"| BLD["Bundle builder - per locale, validated"]
BLD --> CDN[("CDN - versioned bundles")]
APP["Apps - web, mobile, desktop"] -->|"fetch bundle for locale"| CDN
APP --> LIB["i18n runtime - ICU format + fallback"]- Extraction: CI scans the code for keys and source strings, and sends new or changed ones (with context screenshots and comments) to the translation management system.
- Bundles: per locale (and per app module, to keep them small), with a content hash in the name. The published manifest lists the latest bundle per locale.
- Runtime loading: the app ships with a built-in default bundle (so it works offline and at first launch), then fetches the latest bundle version from the CDN, caches it, and uses it on the next screen or launch. That's how translations update without redeploying.
- Validation before publish: ICU syntax is valid, variables match the source (a translation mustn't drop
{name}), and length limits are respected for UI elements.
Quality and UX
- Pseudo-localization in testing: replace strings with accented, longer text ("[Ŝéţţîñĝš !!!]") to catch hard-coded strings and layout overflow early.
- Right-to-left languages (Arabic, Hebrew): mirror the layouts (CSS logical properties), and test them.
- Locale detection: the user's setting first, then the OS or browser language, then region. Keep the locale separate from the country (prices and legal content may depend on the country).
- Performance: load only the needed locale and modules, and cache compiled message formatters.
Wrap-UpWrap-up
Replace UI text with message keys and resolve them at runtime from per-locale catalogs written in ICU MessageFormat (variables, CLDR plural and gender rules), with locale-aware date and number formatting and a fr-CA → fr → en fallback chain that logs misses. Run a CI extraction to translation management to validated bundle pipeline, publish versioned bundles to a CDN that apps fetch and cache (with a built-in default), and ensure quality with pseudo-localization, RTL support and variable checks.