Lesson 09·Unit 3 · The Intl toolbox·Locale matching & fallback chains·Lv 201·~8 min
Locale fallback & negotiation
Your bundle has pt-PT. A user lands from São Paulo with Accept-Language pt-BR. What language do they get? It depends on which of three negotiation algorithms you picked, and whether you knew you'd picked one at all.
By the end
- ·Trace RFC 4647 lookup by hand: pt-BR to pt to your default, truncating from the right and never stepping sideways.
- ·Explain why script beats region when matching Chinese, and why your zh bundles need Hans or Hant on them.
- ·Read the q-values in an Accept-Language header, and say what they feed into and what they never promise.
The problem
Locale fallback ("we don't have your locale; here's our best guess") sounds simple, until you realize there are three RFC-defined algorithms (Basic, Lookup, Best Fit), they produce different answers, and the default JavaScript behaviour differs from the default HTTP Accept-Language behaviour. A Portuguese user from Brazil might see French because nobody specified u-rg- region preference. Worse: 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.
That is by design. Lookup answers a narrow question: whether you have 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. That is why two stacks can negotiate different answers from identical inputs.
Three practical consequences follow. Ship a region-neutral base catalog (pt) whenever you ship any regional variant: 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. When the user explicitly picks a language, store it: the stored choice should beat both algorithms forever.
See it yourself
Try these, in order
Each step triggers a specific failure you should recognize on sight.
- 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 pick a different winner from the same inputs: “what locale does the user get” has three defensible answers.
Background: Classic: the bundle ships European Portuguese, the user is in São Paulo. Best-fit matches them. Pure lookup walks pt-BR → pt → root, which 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), users will mostly not notice. But legal copy, currency, and date format may not match their region. In a hard mismatch (a Brazilian user who gets European Portuguese), the entire copy register is wrong: pt-PT reads to Brazilians the way "colour" and "lorry" read to Americans, on every screen.
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. Your server picks the highest-weighted available locale. Most CDNs do this. Most SPAs ignore it and use navigator.languages instead. |
If you remember one thing
Locale negotiation is an algorithm you choose. Know whether you use 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 your supported bundle list. Never trust the raw header. - Document your 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. Power users in dual-locale countries (Canada, Belgium, Switzerland) will thank you. - Test the negotiation with at least one locale that's not in your bundle; your fallback path is more important than your primary path.
Use this with
Stakeholders
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.
Quick check
3 questions · pass at 2+
Question 1/3
Your bundle has [en, pt-PT]. A user requests pt-BR. What does strict RFC 4647 lookup serve them?
Question 2/3
A zh-Hant-HK user, bundle [zh-Hans-CN, zh-Hant-TW]. Which is the better match and why?
Question 3/3
In the header Accept-Language: de-CH, de;q=0.9, en;q=0.8, what does q mean?