See the string every locale actually renders — and catch the silent gaps where a translation quietly falls back to the wrong language.
When you ship in more than one language, most keys are covered by a fallback chain:
fr-CA borrows from fr, es-MX borrows from es, and everything ultimately lands on a
base language like en. That machinery is great — until it hides a problem. A missing
fr-CA string that falls back to fr is fine. A missing fr-CA string that falls back
all the way to English is a bug your users see and your tests never do.
locale-fallback-simulator loads your translation catalogs and a fallback configuration,
then computes — for every configured locale and every key — exactly which locale
supplied the string the user sees, and at what fallback level. It separates the harmless
region-to-language fallbacks from the silent cross-language leaks that are the real
gaps.
- Zero runtime dependencies. Pure Node 18+ (
Intl-free,fs+ built-ins only). - Library + CLI. Import the engine, or run the reporter over a directory of catalogs.
- CI-ready. Non-zero exit when any locale exceeds a gap budget you choose.
This tool is distributed by cloning the repo (it is intentionally not published to npm). There is nothing to compile and nothing to install:
git clone https://github.com/i18n-l10n/locale-fallback-simulator.git
cd locale-fallback-simulator
node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.jsonRequires Node.js 18 or newer. There are no dependencies, so npm install is not even
necessary — but npm test runs the suite if you want to.
Given a locale, the simulator walks its fallback chain and classifies the result of every key into one of five categories:
| Category | Meaning | A gap? |
|---|---|---|
exact |
Served by the requested locale itself (level 0) | no |
region |
Same language, different region (fr-CA → fr) |
no |
macrolanguage |
Linked via a macrolanguage map (nb → no) |
no |
cross-language |
A different language (fr-CA → en) — a silent leak |
yes |
missing |
No locale in the chain had the key at all | — |
A gap is specifically a cross-language fallback: the user asked for one language and
silently got another. That region-to-language nuance is the whole point — a coverage
percentage alone will happily report "100%" for a locale that is quietly showing English
to French-Canadian users. See
Setting up graceful fallback chains for missing strings
and
Region-to-language fallback without a 404
for the design principles this tool checks.
node bin/fallback-sim.js [options] <catalogs-dir>
Options:
--config <path> Fallback config JSON (default: <catalogs-dir>/fallback.config.json if present)
--format <fmt> Output format: pretty | json | csv (default: pretty)
--only <locale> Report a single locale only
--max-gaps <n> Exit non-zero if any locale has MORE than <n> cross-language gaps.
Use 0 in CI to fail on any silent base-language leak.
(default: disabled — always exit 0)
-h, --help Show this help
Every *.json file in <catalogs-dir> is loaded as a catalog whose filename is the
locale (fr-CA.json → fr-CA). Keys may be nested or flat — nested objects are
flattened to dot-delimited keys ({"app":{"title":"…"}} → app.title). The
fallback.config.json file is never treated as a catalog.
Running the bundled example:
$ node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json
locale-fallback-simulator — 9 locale(s), 10 key(s)
config: examples/fallback.config.json default=en
en en ████████████ 100% in-language · 0 gaps
fr fr → en ██████████░░ 80% in-language ⚠ 2 gap(s)
fr-CA fr-CA → fr → en ██████████░░ 80% in-language ⚠ 2 gap(s)
es es → en ████████████ 100% in-language · 0 gaps
es-MX es-MX → es → en ████████████ 100% in-language · 0 gaps
pt-BR pt-BR → pt → en ██████████░░ 80% in-language ⚠ 2 gap(s)
de-AT de-AT → de → en ███████░░░░░ 60% in-language ⚠ 4 gap(s)
nb nb → no → en ███████████░ 90% in-language ⚠ 1 gap(s)
en-CA en-CA → en-GB → en ████████████ 100% in-language · 0 gaps
Silent cross-language fallbacks (base-language leaks)
fr checkout.tax_note → en "Taxes calculated at checkout"
fr errors.network → en "Network connection lost"
fr-CA checkout.tax_note → en "Taxes calculated at checkout"
fr-CA errors.network → en "Network connection lost"
pt-BR checkout.tax_note → en "Taxes calculated at checkout"
pt-BR errors.network → en "Network connection lost"
de-AT app.tagline → en "Ship in every language"
de-AT checkout.button → en "Pay now"
de-AT checkout.tax_note → en "Taxes calculated at checkout"
de-AT errors.network → en "Network connection lost"
nb checkout.tax_note → en "Taxes calculated at checkout"
Missing entirely: noneRead the report like this:
es-MXis 100% in-language with 0 gaps even though it defines only two of its own strings — every other key correctly borrows fromes. That is a healthy region → language fallback.fr-CAis also "80% in-language" but has 2 gaps:checkout.tax_noteanderrors.networkare missing from bothfr-CAandfr, so users see English. The bar looks fine; the gap list is what you act on.nb→nois a macrolanguage fallback (Norwegian Bokmål borrowing from the broader Norwegian catalog), so those six keys are counted as in-language, not gaps — only the one key missing everywhere leaks to English.
--format json prints the full resolution matrix — the union of keys, each locale's
resolved chain, and a per-key cell with value, resolvedLocale, level, category, and
the boolean flags:
$ node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json --only fr-CA --format json
{
"keys": [ "app.tagline", "app.title", "checkout.button", ... ],
"locales": [ "fr-CA" ],
"byLocale": {
"fr-CA": {
"locale": "fr-CA",
"chain": [ "fr-CA", "fr", "en" ],
"cells": {
"app.tagline": {
"value": "Déployez dans toutes les langues",
"resolvedLocale": "fr-CA",
"level": 0,
"category": "exact",
"isFallback": false,
"isMissing": false,
"isGap": false
},
"checkout.tax_note": {
"value": "Taxes calculated at checkout",
"resolvedLocale": "en",
"level": 2,
"category": "cross-language",
"isFallback": true,
"isMissing": false,
"isGap": true
}
},
"stats": { "total": 10, "exact": 2, "region": 6, "crossLanguage": 2, "gaps": 2, ... }
}
},
"totals": { "gaps": 11, ... }
}--format csv emits one row per locale × key — ideal for a spreadsheet triage:
$ node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json --only es-MX --format csv
locale,key,resolvedLocale,level,category,isGap,isMissing,value
es-MX,app.title,es,1,region,false,false,Panel de Acme
es-MX,checkout.button,es-MX,0,exact,false,false,Pagar
es-MX,nav.logout,es-MX,0,exact,false,false,Salir
es-MX,nav.settings,es,1,region,false,false,Configuración
...Set a gap budget with --max-gaps. With 0, any cross-language leak fails the build; the
tool prints the pretty report to stdout and the offenders to stderr, then exits 1:
$ node bin/fallback-sim.js examples/catalogs --config examples/fallback.config.json --max-gaps 0
... (report) ...
FAIL: fr has 2 cross-language gap(s) (max 0)
FAIL: fr-CA has 2 cross-language gap(s) (max 0)
FAIL: pt-BR has 2 cross-language gap(s) (max 0)
FAIL: de-AT has 4 cross-language gap(s) (max 0)
FAIL: nb has 1 cross-language gap(s) (max 0)
$ echo $?
1This slots directly into a translation CI gate. For the wider pattern, see GitHub Actions i18n CI gates and Failing the build on untranslated keys.
# .github/workflows/i18n.yml
- name: Check for silent cross-language fallbacks
run: node bin/fallback-sim.js locales --config locales/fallback.config.json --max-gaps 0The config file is JSON. Every field is optional; an empty {} config falls back to
automatic BCP-47 truncation with en as the base.
| Field | Type | Purpose |
|---|---|---|
defaultLocale |
string |
The ultimate base language appended to every automatic chain. Default "en". |
locales |
string[] |
Which locales to simulate. Defaults to the union of catalog files and chains keys. |
chains |
{ [locale]: string[] } |
Explicit fallback chains, used verbatim and taking precedence over truncation. |
macrolanguages |
{ [lang]: string } |
Insert a macrolanguage at the language level ({"nb":"no"} → nb … no … default). |
The bundled examples/fallback.config.json:
{
"defaultLocale": "en",
"locales": ["en", "fr", "fr-CA", "es", "es-MX", "pt-BR", "de-AT", "nb", "en-CA"],
"chains": {
"en-CA": ["en-CA", "en-GB", "en"],
"pt-BR": ["pt-BR", "pt", "en"]
},
"macrolanguages": { "nb": "no", "nn": "no" }
}Note how en-CA uses an explicit chain to prefer British spelling (en-GB) before the
base en — something plain truncation (en-CA → en) would skip. Chain construction and
the trade-offs between explicit chains, truncation, and macrolanguage hints are covered in
Fallback chain configuration
and
Locale negotiation strategies.
The engine is a plain ES module you can import directly:
import {
buildFallbackChain,
resolveKey,
classifyResolution,
simulate,
flatten,
normalizeLocale,
} from './src/index.js';
buildFallbackChain('de-AT', { defaultLocale: 'en' });
// => ['de-AT', 'de', 'en']
buildFallbackChain('nb', { defaultLocale: 'en', macrolanguages: { nb: 'no' } });
// => ['nb', 'no', 'en']
const catalogs = {
en: { 'checkout.tax_note': 'Taxes calculated at checkout' },
fr: { /* no tax_note here */ },
};
resolveKey('checkout.tax_note', 'fr-CA', catalogs, { defaultLocale: 'en' });
// => { value: 'Taxes calculated at checkout', resolvedLocale: 'en', level: 1,
// isFallback: true, isMissing: false }| Function | Returns |
|---|---|
buildFallbackChain(locale, options) |
string[] — the normalized, de-duplicated fallback chain (index 0 = the locale itself). |
resolveKey(key, locale, catalogs, config) |
{ value, resolvedLocale, level, isFallback, isMissing }. |
classifyResolution(locale, resolution, config) |
'exact' | 'region' | 'macrolanguage' | 'cross-language' | 'missing'. |
simulate(catalogs, config) |
{ keys, locales, byLocale, totals } — the full matrix and per-locale stats. |
flatten(obj) |
Flatten a nested catalog into dot-delimited keys. |
normalizeLocale(tag) |
Canonicalize casing/separators (pt_br → pt-BR). |
catalogs passed to resolveKey/simulate use flat keys; use flatten() first if
your catalogs are nested (the CLI does this for you). Empty-string entries are treated as
untranslated so an empty placeholder never masks a real gap.
Per-locale stats include exact, region, macrolanguage, crossLanguage, missing,
gaps (an alias for crossLanguage), coveragePct (has any value), inLanguagePct
(exact + region + macrolanguage), and exactPct.
| Code | Meaning |
|---|---|
0 |
Success. No locale exceeded --max-gaps (or the flag was not used). |
1 |
At least one locale's cross-language gap count exceeded --max-gaps. |
2 |
Usage error (bad option, missing/invalid directory, unparseable JSON). |
examples/catalogs/— thirteen realistic partial catalogs (baseen, plusfr/fr-CA,es/es-MX,pt/pt-BR,de/de-AT, Norwegiannb/no, anden-GB/en-CA).examples/fallback.config.json— the configuration used in every example above.test/— anode:testsuite covering chain building (truncation, explicit chains, macrolanguage maps, normalization),resolveKeylevels, gap classification (region vs cross-language vs macrolanguage), the simulation math, and the CLI end-to-end.
Run the suite:
npm test # or: node --testℹ tests 35
ℹ pass 35
ℹ fail 0
Deeper background on the concepts this tool exercises, from the i18n & l10n Pipelines reference:
- Fallback chain configuration
- Setting up graceful fallback chains for missing strings
- Region-to-language fallback without a 404
- Locale negotiation strategies
- Detecting locale from the Accept-Language header — edge cases
MIT © 2026 i18n-l10n
Built as an open tool for the i18n & l10n Pipelines reference.