Skip to content
Locale Lab

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.

BCP-47RFC 4647Accept-LanguageLocaleMatcher

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.

  1. 1
    Load 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.
  2. 2
    Now 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.
  3. 3
    Load “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.
  4. 4
    Load “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

  1. pt-BRpreference #1
    pt-BRpt→ root → ultimate fallback
  2. ptpreference #2
    pt→ root → ultimate fallback
  3. enpreference #3PICKED
    en

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

AlgorithmSpecBehaviour
Lookup deterministicRFC 4647 §3.4Strips 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 heuristicIntl ECMA-402Engine-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-onlyRFC 9110 §12.5.4The 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-localematcher on the server to negotiate locale from Accept-Language against 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-gbzzzz means "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

EngineeringProduct

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.

W3C: Choosing a language tag ↗

Terms in this chapter

Where to read more

Related chapters