Chapter 09·The Intl toolbox·~8 min
Locale fallback & negotiation
Three negotiation algorithms decide which language a request gets, and most teams never chose one.
The problem
Locale fallback ("the requested locale isn't available; serve the best guess") runs one of three procedures: RFC 4647 lookup, a library's best-fit matcher, or raw Accept-Language q-value order, and they give different answers from the same inputs. A Portuguese speaker from Brazil can land on English while pt-PT sits in the bundle, because lookup truncates pt-BR to pt and never reaches a sibling region. The bug is invisible to anyone testing in their own locale.
How it works
What the runtime does with pt-BR
A locale tag is a path, not a label. pt-BR decomposes into language (pt), region (BR), optionally script and variants in between. RFC 4647 lookup resolves a request by walking that path from the right: try pt-BR, then pt, then fall back to the default. The walk never steps sideways: pt-PT may look adjacent to a human, but to lookup it is not on the path.
Lookup answers a narrow question: whether the bundle holds something the user asked for. Best-fit matching answers the friendlier question of the least-bad alternative. It is a different algorithm that scores distances between locales (CLDR ships the distance data) and can match pt-BR to pt-PT, or zh-Hant-HK to zh-Hant-TW. Browsers and libraries implement best-fit differently, so two stacks can disagree on identical inputs.
Ship a region-neutral base catalog (pt) whenever any regional variant ships: it is the cheap net under every sibling region. Tag Chinese with scripts (zh-Hans, zh-Hant), because script outranks region in every sane match. And when a user explicitly picks a language, store the choice: it should beat both algorithms forever.
The demo
Try these, in order
Each step reproduces one specific failure in the demo below.
- 1Load the scenario “Brazilian user, bundle has only pt-PT” and follow the fallback walk. pt-BR → pt → root → en. The user gets English while Portuguese sits in the bundle: lookup truncates the request tag and never “reaches across” to a sibling region.
- 2Now add pt to the bundle field (keep pt-PT too). The walk now catches at the base tag: pt-BR users get pt. Shipping a region-neutral base catalog is the cheap fix for sibling-region traffic.
- 3Load “Hong Kong user, bundle has zh-Hans + zh-Hant”. zh-Hant-HK matches zh-Hant-TW: the script subtag (Hant) outranks region. Chinese negotiation without script tags is guaranteed wrong for someone.
- 4Load “Swiss user” and read all three algorithm columns. Lookup, best-fit, and Accept-Language q-values can each crown a different winner: “what locale does the user get” has three defensible answers.
Background: The bundle ships European Portuguese, the user is in São Paulo. Best-fit matches them; pure lookup walks pt-BR → pt → root and falls through to en.
The fallback walk, per user-preferred tag
- pt-BRpreference #1pt-BRpt→ root → ultimate fallback
- ptpreference #2pt→ root → ultimate fallback
- enpreference #3PICKEDen
Negotiation result
User gets en (matched from preference en). In a soft mismatch (de-AT user, de-DE bundle), most users will not notice, though legal copy, currency, and date format may not match their region. In a hard mismatch (a Brazilian user who gets European Portuguese), the whole copy register is wrong: pt-PT reads to Brazilians the way "colour" and "lorry" read to Americans.
Three negotiation algorithms, one input
| Algorithm | Spec | Behaviour |
|---|---|---|
| Lookup deterministic | RFC 4647 §3.4 | Strips subtags from the right (zh-Hant-HK → zh-Hant → zh) until a bundle entry matches. Predictable, portable, and the default in most i18n libraries. |
| Best-fit heuristic | Intl ECMA-402 | Engine-defined. V8 uses ICU's LocaleMatcher with CLDR's languageMatching.xml, so it knows that pt-BR ↔ pt-PT is a reasonable swap and en ↔ ja is not. |
| Accept-Language q-vals HTTP-only | RFC 9110 §12.5.4 | The browser sends q=0.9, q=0.5 weights. The server picks the highest-weighted available locale. Most CDNs do this. Most SPAs ignore it and use navigator.languages instead. |
The short version
Locale negotiation is a chosen algorithm. Know whether the stack uses RFC 4647 lookup, best-fit matching, or Accept-Language parsing. The three give different answers from the same inputs.
What to do about it
- Use
@formatjs/intl-localematcheron the server to negotiate locale fromAccept-Languageagainst the supported bundle list. Never trust the raw header. - Document the fallback chain explicitly:
pt-BR → pt → en. Stakeholders need to know what language a user from an unsupported region will see. - For BCP-47 region preferences (
en-US-u-rg-gbzzzzmeans "English in US, but use UK formats"), surface this in account settings; users in dual-format countries (Canada, Belgium, Switzerland) need language and formats to vary independently. - Test the negotiation with at least one locale that's not in the bundle; the fallback path is more important than the primary path.
Where this comes up
Who it concerns
Moments
- ·Adding a new locale to the bundle
- ·Server-side rendering setup
- ·Debugging 'why does this user see French?'
Field note
BCP-47 even has a region code for “Spanish, but not Spain's”: es-419, Latin America, borrowed from the UN M.49 area code 419. It exists because twenty per-country Spanish catalogs do not scale, and because es-ES vocabulary reads foreign across an ocean. Spain's ordenador is Latin America's computadora, and its coche their carro, on every screen.