Fix Flutter Serving Simplified Chinese to zh-Hant Users
A user in Taipei opens your app and sees 简体字 instead of 繁體字 — or worse, plain English. Nothing crashed, flutter gen-l10n ran clean, and your app_zh_TW.arb file sits right there in lib/l10n. This is one of the most-reported Flutter i18n complaints — flutter/flutter#159216 ("certain languages are not properly detected, e.g. Simplified Chinese") is a recent example, and it was closed as invalid because, in almost every case, the framework is doing exactly what your ARB file names told it to do. The bug is in how Chinese locales are named and matched.
This post walks through Flutter's real locale-resolution algorithm, shows exactly why app_zh.arb + app_zh_TW.arb silently misroutes Traditional Chinese devices, and gives you the copy-paste fix: script-coded ARB files plus a defensive localeListResolutionCallback that matches on scriptCode before countryCode.
Why Chinese is different: scriptCode matters more than countryCode
Most languages resolve fine on languageCode alone. Chinese doesn't, because "zh" spans two writing systems:
- Hans (Simplified) — mainland China (CN), Singapore (SG)
- Hant (Traditional) — Taiwan (TW), Hong Kong (HK), Macau (MO)
A device in Hong Kong typically reports zh-Hant-HK. A device in Taiwan reports zh-Hant-TW. If your supported locales only carry country codes, any Hant device whose country you didn't list falls through to bare zh — and since Flutter's synthetic Cupertino/Material defaults treat plain zh as Simplified (see flutter/flutter#26941), your app_zh.arb is almost certainly Simplified too. Traditional-script users get Simplified Chinese. If zh isn't supported at all, they get your first supported locale — usually English.
Flutter's actual resolution algorithm, step by step
Flutter's default resolver is basicLocaleListResolution. For each locale in the device's preferred list, it tries, in order:
- Perfect match — languageCode + scriptCode + countryCode all equal.
- languageCode + scriptCode match.
- languageCode + countryCode match.
- languageCode only match (with a look-ahead: it may defer to the next preferred locale if that shares the language and matches better).
- countryCode only match, as a last resort across the whole list.
- Otherwise: the first locale in
supportedLocaleswins. This is why apps "fall back to English" —enis listed first.
Note what's missing: there is no step that maps TW to Hant. The algorithm compares subtags literally; it has no CLDR-style likely-subtags inference. The docs say it "prioritizes speed at the cost of slightly less appropriate resolutions for edge cases" — and Chinese is the edge case.
Tracing the failure with app_zh.arb + app_zh_TW.arb
Say your lib/l10n contains app_en.arb, app_zh.arb (Simplified) and app_zh_TW.arb (Traditional). gen_l10n generates supportedLocales = [en, zh, zh_TW] — note neither zh entry has a scriptCode.
- Device
zh-Hant-TW→ step 1 fails (no supported locale hasHant), step 2 fails, step 3 matcheszh_TW. ✅ Lucky. - Device
zh-Hant-HK→ steps 1–2 fail, step 3 fails (nozh_HK), step 4 matches barezh→ Simplified Chinese served to a Hong Kong user. ❌ - Device
zh-Hant(no region — common on desktop/web and some Android profiles) → falls to step 4 → barezh→ Simplified. ❌ - Remove
app_zh.arband the HK device matches nothing → English. ❌
The app never errors. It just quietly ships the wrong script — the exact symptom developers report on issue #159216 and its many duplicates (#51645, #146966).
The fix, part 1: script-coded ARB file names
Rename your ARB files so gen_l10n emits script-coded locales. This is the naming the official internationalization guide itself recommends for Chinese:
lib/l10n/
app_en.arb # English
app_zh.arb # generic Chinese fallback (Simplified content)
app_zh_Hans.arb # Simplified Chinese
app_zh_Hant.arb # Traditional Chinese
app_zh_Hant_TW.arb # optional: Taiwan-specific overrides
app_zh_Hant_HK.arb # optional: Hong Kong-specific overrides
Case matters: it's app_zh_Hant.arb, not app_zh_hant.arb. gen_l10n distinguishes a script from a country by the subtag's shape — four letters, title-case (Hans, Hant) is a script; two uppercase letters (TW, HK) is a country. Get the casing wrong and your "app_zh_Hant.arb not working" mystery is just a misparsed subtag.
With this layout, gen_l10n generates:
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('zh'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW'),
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant', countryCode: 'HK'),
];
Now re-run the trace: a zh-Hant-HK device hits step 1 (zh_Hant_HK), a zh-Hant-MO device hits step 2 (zh_Hant), a bare zh-Hant device hits step 2. Every Traditional-script device lands on Traditional content. That alone fixes the common case.
The fix, part 2: a defensive localeListResolutionCallback
One gap remains: some platforms and older OS versions report legacy tags without a script — zh-TW, zh-HK — and if you ever drop the country-specific ARB files, step 3 can't save them and they'd fall through to bare zh again. A small localeListResolutionCallback closes that hole by inferring the script from the country before matching, then delegating everything non-Chinese to the default algorithm:
import 'package:flutter/material.dart';
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: (deviceLocales, supportedLocales) {
for (final locale in deviceLocales ?? const <Locale>[]) {
if (locale.languageCode == 'zh') {
// Match on script first; infer it from the region for legacy
// tags like zh-TW / zh-HK that arrive without a scriptCode.
final script = locale.scriptCode ??
(const {'TW', 'HK', 'MO'}.contains(locale.countryCode)
? 'Hant'
: 'Hans');
return supportedLocales.firstWhere(
(s) =>
s.languageCode == 'zh' &&
s.scriptCode == script &&
s.countryCode == locale.countryCode,
orElse: () => supportedLocales.firstWhere(
(s) => s.languageCode == 'zh' && s.scriptCode == script,
orElse: () => const Locale('zh'),
),
);
}
// Stop at the first device language the app supports at all,
// so a [fr, zh] user still gets French.
if (supportedLocales.any((s) => s.languageCode == locale.languageCode)) {
break;
}
}
return basicLocaleListResolution(deviceLocales, supportedLocales);
},
);
The callback receives the full ordered list of device languages, not just the top one, so a user whose phone prefers [zh-Hant-TW, en] resolves Chinese, while [fr-FR, zh-CN] resolves French if you ship it. The key inversion versus the default algorithm: for zh we decide the script first and the country second — the opposite of basicLocaleListResolution's step order, and the right priority for Chinese.
Don't forget iOS: CFBundleLocalizations gates everything
If Traditional Chinese works on Android but iOS devices still get English, the resolution algorithm may never even see zh-Hant: iOS filters the locales it hands your app through Info.plist. Add every language you support to CFBundleLocalizations:
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh</string>
<string>zh-Hans</string>
<string>zh-Hant</string>
</array>
We've covered this whole failure mode in depth in Flutter iOS stuck in English? Fix CFBundleLocalizations. And the same subtag-matching logic bites regional variants of other languages too — see Flutter serving the wrong Spanish? Fix regional ARBs for the es-MX/es-419 version of this story.
Quick debug checklist
- Print what the OS actually reports:
debugPrint('${WidgetsBinding.instance.platformDispatcher.locales}')— look for whetherHantis present or you're getting legacyzh-TWtags. - Check the generated
supportedLocaleslist — if no entry has ascriptCode, your ARB names are the problem. - Verify ARB casing:
Hans/Hantexactly. - On iOS, confirm
CFBundleLocalizationsbefore blaming Dart code.
Keep zh_Hans and zh_Hant in sync without hand-editing JSON
Once you split Chinese into three or five ARB files, keeping every key present and every ICU plural well-formed across them gets tedious fast. FlutterLocalisation's ARB editor lets you edit app_zh_Hans.arb, app_zh_Hant.arb and the rest side by side in a UI instead of raw JSON, manage translations across all your locales in one place, and its ICU plural-syntax validation flags locales that are missing a plural category the language actually requires. There's a free tier — Try FlutterLocalisation free and ship the right script to every Chinese-speaking user.