Fallback Chain Configuration

A fallback chain resolves a requested locale to the best available translation by walking from exact match down to a guaranteed default — so a request for es-AR lands on es-419, then es, then en, instead of rendering the raw key checkout.cta.submit to a user. Get the ordering wrong and you ship empty strings, mismatched plurals, or a 404 on /es-AR/checkout; get it right and every locale degrades gracefully to readable text.

This is the resolver that sits between locale negotiation and bundle hydration. Once Locale Negotiation Strategies have produced a resolved tag, the fallback chain decides which actual resource file backs each key. The ordering is fixed and deterministic: exact locale → regional variant → base language → default. Everything below makes that chain explicit, cache-safe, and impossible to leak raw keys through.

Fallback cascade for a missing key A request for es-AR checks the exact bundle, falls to the es-419 regional variant, then the es base language, then the en default; each step is tried only when the key is absent in the prior bundle. resolve("checkout.cta", "es-AR") Exact locale es-AR Regional variant es-419 Base language es Default en miss miss RFC 4647 "lookup" rule Truncate at the last "-" each step; never widen. Stop at the first bundle that owns the key. If the default also misses → return the key marker, emit telemetry, never render raw key to the user.
The cascade tries each narrower-to-broader bundle in order and terminates at a guaranteed default.

Prerequisites

Concept & spec — what “fallback” actually means

The chain is the lookup algorithm defined in RFC 4647 §3.4. Given a requested tag, lookup produces an ordered list of candidate tags by progressively truncating the rightmost subtag at each hyphen — zh-Hant-TWzh-Hantzh — and never adding subtags back. The first candidate that names an available bundle wins. This is distinct from RFC 4647’s “filtering” mode, which returns all matching tags; for runtime string resolution you always want lookup, because you need exactly one source per key.

RFC 4647 lookup versus filtering On the left, lookup truncates zh-Hant-TW to zh-Hant to zh, stopping at the first bundle that owns the key so exactly one source is chosen. On the right, filtering expands the range zh-* into the full set zh-Hant-TW, zh-Hant, zh-Hans, zh. Lookup is what string resolution needs; widening a tag by appending subtags is filtering, not lookup. Lookup — narrow to one zh-Hant-TW drop -TW zh-Hant drop -Hant zh first owner ⇒ winner Exactly one source per key Filtering — return a set zh-* expand all matching tags: zh-Hant-TW zh-Hant zh-Hans zh For negotiation lists, not resolution Never widen: appending subtags (es → es-ES) is filtering, not lookup — it picks the wrong regional default.
Lookup truncates the request to a single winning bundle; filtering fans a range out to every match. Runtime string resolution always uses lookup.

Two refinements matter in practice. First, a regional variant like es-419 (Latin American Spanish, a UN M.49 region code) often sits between es-AR and es and is a far better fallback than the Iberian es-ES default. RFC 4647 truncation alone won’t insert it — you supply that link explicitly via a fallbackLng map. Second, the chain must terminate at a verified default; per the parent Core i18n Architecture & Locale Negotiation model, the default bundle is the one hard guarantee in the whole pipeline, so it is the only bundle whose 100% key coverage is enforced at build time.

Canonicalize before you resolve. Intl.getCanonicalLocales('es-419') returns ['es-419'], and new Intl.Locale('zh-hant-tw').baseName normalizes casing. Resolving against un-normalized tags is the single most common cause of a “bundle exists but never matches” bug.

One boundary worth naming: the chain decides only which string bundle backs a key. It does not change how numbers, dates, and currency render — those resolve independently through Intl against the originally requested tag, per date and number formatting standards. A page can correctly serve an es string while still formatting 1.234,56 € for the requesting es-AR user.

Step-by-step implementation

1. Canonicalize the requested tag

Normalize casing and structure before the chain runs. A mixed-case es-ar must become es-AR or it will miss every bundle keyed in canonical form.

export function canonical(tag: string): string {
  try {
    return Intl.getCanonicalLocales(tag)[0] ?? tag;
  } catch {
    return tag; // malformed tag — let the chain fall through to default
  }
}

2. Build the RFC 4647 lookup chain

Generate the ordered candidate list by truncating right-to-left, then append explicit fallbackLng links and the default. De-duplicate while preserving order so a regional variant injected via the map isn’t visited twice.

type FallbackMap = Record<string, string[]>;

export function buildChain(
  tag: string,
  fallbackLng: FallbackMap,
  defaultLocale: string,
): string[] {
  const chain: string[] = [];
  let cur = canonical(tag);

  // RFC 4647 lookup: truncate at each hyphen.
  while (cur) {
    chain.push(cur);
    for (const extra of fallbackLng[cur] ?? []) chain.push(extra);
    const i = cur.lastIndexOf('-');
    if (i < 0) break;
    cur = cur.slice(0, i);
  }
  chain.push(...(fallbackLng['*'] ?? []), defaultLocale);

  return [...new Set(chain)]; // order-preserving de-dupe
}
// buildChain('es-AR', { 'es-AR': ['es-419'] }, 'en')
// → ['es-AR', 'es-419', 'es', 'en']

3. Resolve a key against the chain

Walk the chain and return the first bundle that owns the key. Crucially, never return the raw key to the renderer — return a typed miss so the caller can decide on a visible marker only in non-production.

const MISS = Symbol('i18n.miss');

export function resolveKey(
  key: string,
  chain: string[],
  bundles: Record<string, Record<string, string>>,
): string | typeof MISS {
  for (const loc of chain) {
    const v = bundles[loc]?.[key];
    if (v != null) return v;
  }
  return MISS;
}

4. Guard the render boundary

Convert a miss into safe output exactly once, at the edge of your render layer, and emit telemetry. This is the only place the raw key may surface, and only behind a dev flag.

export function render(key: string, chain: string[], bundles: any): string {
  const out = resolveKey(key, chain, bundles);
  if (out === MISS) {
    track('i18n.miss', { key, requested: chain[0] });
    return process.env.NODE_ENV === 'production' ? '' : `${key}`;
  }
  return out;
}

5. Validate ICU parity across the chain

A fallback string must keep the same placeholders as the source, or interpolation breaks at runtime. Compare parsed ICU structure before trusting a fallback — covered in depth in the ICU Message Format Deep Dive. This matters most when the fallback language has different pluralization rules from the source: a bundle that falls from pl to en inherits English’s two plural categories, so a {count, plural, ...} arm authored for Polish’s four categories must still parse cleanly.

import { parse } from '@formatjs/icu-messageformat-parser';

export function placeholders(msg: string): Set<string> {
  const out = new Set<string>();
  for (const el of parse(msg)) if ('value' in el && el.type !== 0) out.add(String(el.value));
  return out;
}
// In CI: assert placeholders(fallback).size matches the source for every shared key.

Lookup chain resolver

With JavaScript enabled, this panel runs the RFC 4647 truncation live against a small bundle set and shows where the chain terminates. The worked example above covers the same ground without it.

Configuration reference

Option Type Description / default
defaultLocale string Terminal locale with enforced 100% coverage. Default 'en'.
fallbackLng Record<string, string[]> Explicit links inserted into the lookup chain (e.g. { 'es-AR': ['es-419'] }). Key '*' applies to all.
maxDepth number Hard cap on chain length; cycle/runaway guard. Default 4.
strictMode boolean Throw on ICU parity mismatch instead of warning. Default false in prod, true in CI.
lowerCaseLng boolean Canonicalize tags before lookup. Default true. Disable only if bundles are keyed in raw form.
returnNull boolean If true, missing keys return null (typed miss) instead of ''. Default true.
nonExplicitSupportedLngs boolean Treat de as covering de-AT/de-CH without explicit map entries. Default true.

Framework variants

React / i18next

i18next ships this chain natively. fallbackLng accepts a map, and nonExplicitSupportedLngs enables base-language coverage. Keep returnEmptyString: false and parseMissingKeyHandler to avoid raw-key leakage.

i18n.init({
  fallbackLng: { 'es-AR': ['es-419', 'es'], default: ['en'] },
  nonExplicitSupportedLngs: true,
  returnEmptyString: false,
  parseMissingKeyHandler: (key) => (import.meta.env.DEV ? `${key}` : ''),
});

Next.js (App Router)

Resolve the chain in middleware so the server component receives a single concrete locale, and serve 404 only for tags outside supportedLngs — never for a missing string. Region-to-language redirects belong here; see region-to-language fallback without a 404 for the redirect-vs-rewrite decision.

Vue i18n

Vue i18n exposes fallbackLocale, which accepts the same map shape ({ 'es-AR': ['es-419', 'es'], default: ['en'] }) plus fallbackWarn to surface gaps in dev. Set missingWarn: false in production builds.

Angular

Angular’s built-in @angular/localize compiles one bundle per locale at build time and has no runtime chain — an unmatched region simply serves whatever locale the deploy path points at. Recover a real fallback by building the tiered lookup yourself in a runtime message service, or adopt a runtime library (@ngx-translate/core) whose MissingTranslationHandler walks a chain you supply. Either way, resolve the tag in a route guard and set the base language on <html lang> so the same string never renders raw.

import { MissingTranslationHandler, MissingTranslationHandlerParams } from '@ngx-translate/core';

export class ChainedMissingHandler implements MissingTranslationHandler {
  handle(params: MissingTranslationHandlerParams): string {
    const chain = buildChain(params.translateService.currentLang, fallbackLng, 'en');
    for (const loc of chain) {
      const hit = params.translateService.translations[loc]?.[params.key];
      if (hit != null) return hit;
    }
    return isDevMode() ? `${params.key}` : '';
  }
}

Node.js backend / SSR

On the server, build the chain once per request from the negotiated tag and memoize per-locale chains (they’re pure functions of tag + config). Pass the resolved chain — not the raw header — to the template layer so SSR and client hydration agree.

Cache-aware SWR headers

A fallback resolver that changes which bundle backs a key must not be cached as if it were locale-agnostic. Vary the edge cache on the resolved locale and serve stale-while-revalidate so a cold variant bundle never blocks render:

Cache states for one resolved locale A cache entry keyed on the resolved locale starts fresh inside the max-age window. When max-age lapses it becomes stale but is still served instantly, while the first hit after the lapse triggers a background revalidation that re-runs the fallback chain at the origin and stores a new bundle under the same resolved locale. A locale cache entry through stale-while-revalidate fresh max-age window stale served instantly revalidating origin resolves chain max-age lapses first hit after lapse new bundle stored under the resolved locale
The reader never waits on the chain: a stale variant is served while the origin resolves the new one.
Vary: Accept-Language, Cookie
Cache-Control: public, max-age=600, stale-while-revalidate=86400

Vary: Accept-Language tells the CDN that two requests differing only by language are distinct cache entries — without it, the first visitor’s resolved locale gets served to everyone. stale-while-revalidate=86400 lets the edge return a slightly stale localized page instantly while it refetches in the background, so a translation update never forces a synchronous origin fetch on the user’s critical path. When you redirect a region tag to its language (e.g. es-ARes-419), use a 302 with Vary, not a 301, so the mapping stays revisable.

Verification

Assert the chain shape and the no-raw-key guarantee in CI. The first test pins the RFC 4647 ordering; the second proves every key in every locale resolves to non-empty text through the default.

import { test, expect } from 'vitest';
import { buildChain, render } from './fallback';

test('chain follows exact → regional → base → default', () => {
  expect(buildChain('es-AR', { 'es-AR': ['es-419'] }, 'en'))
    .toEqual(['es-AR', 'es-419', 'es', 'en']);
});

test('no key ever renders raw to a user', () => {
  const bundles = load(); // all locale json
  for (const loc of supportedLngs) {
    const chain = buildChain(loc, fallbackLng, 'en');
    for (const key of Object.keys(bundles.en)) {
      expect(render(key, chain, bundles)).not.toBe(`${key}`);
    }
  }
});
# Expected output
✓ chain follows exact → regional → base → default
✓ no key ever renders raw to a user

Wire this as a build gate so a PR that adds a key to the default bundle without coverage — or breaks the ordering — fails before merge.

Common pitfalls

  • Raw key leaking to users. A missing string renders checkout.cta.submit in the UI. Set returnEmptyString: false and a parseMissingKeyHandler, and gate the visible marker behind a dev flag. Build the tiered chain properly via setting up graceful fallback chains for missing strings.
  • 404 on a valid region. Routing treats /es-AR/ as unknown because only es has a bundle. Resolve at the routing layer and redirect/rewrite; never 404 a string miss. See region-to-language fallback without a 404.
  • Un-canonicalized tags. es_ar or ES-ar silently miss every bundle. Always run Intl.getCanonicalLocales first.
  • Widening instead of narrowing. Appending subtags (eses-ES) is filtering, not lookup, and picks the wrong regional default. Only ever truncate.
  • ICU placeholder drift. A fallback string drops {count} and the plural breaks at runtime. Gate on ICU parity in CI.
  • Stale edge cache. Missing Vary: Accept-Language serves one visitor’s locale to all. Always vary on the locale signal.

FAQ

What is the correct fallback order for locales?

Exact locale → regional variant → base language → default, following RFC 4647 lookup: truncate the requested tag at each hyphen (es-ARes-419esen), inserting any explicit regional variant via a fallbackLng map, and terminate at a default bundle with guaranteed 100% key coverage.

How do I stop raw translation keys from showing in the UI?

Make missing keys return a typed miss (null or empty string) rather than the key itself, and convert that miss to safe output at the render boundary. Set returnEmptyString: false plus a parseMissingKeyHandler that only emits a visible ⟦key⟧ marker in development, never in production.

What is the difference between RFC 4647 lookup and filtering?

Lookup returns exactly one best-matching tag by progressively truncating the request, which is what runtime string resolution needs. Filtering returns the full set of tags matching a range and is meant for content negotiation lists, not for picking a single source bundle per key.

Should a missing locale return a 404?

No. A 404 is correct only when the requested language is entirely unsupported. A string miss should fall through the chain to the default; a region miss should redirect or rewrite to the base language. Resolve this at the routing layer with a 302, not by returning a 404.

How do edge caches interact with fallback chains?

Because the resolver changes which bundle backs a page per locale, the CDN must Vary: Accept-Language (and Cookie if you use a locale cookie) so each resolved locale is a distinct cache entry. Add stale-while-revalidate so a cold variant bundle is served instantly from cache while it revalidates in the background.

Part of Core i18n Architecture & Locale Negotiation.