Fix Flutter Web Tofu Boxes for Chinese, Japanese, Arabic
You shipped a localized Flutter app. app_zh.arb, app_ja.arb, and app_ar.arb render perfectly on iOS and Android. Then you run flutter build web, open the site, switch locale — and every string is a row of empty rectangles: □□□□. Sometimes the text flashes in correctly a second later. Sometimes it never arrives at all.
The first thing to know: your ARB files, your AppLocalizations delegates, and your supportedLocales are almost certainly correct. If the strings reach the widget tree on mobile, they reach it on web too. Tofu boxes are a glyph coverage problem, not a localization problem. The engine has the right string; it has no font that can draw those code points at the moment it lays out the paragraph.
What the engine actually does with a missing glyph
Since the HTML renderer was removed in Flutter 3.29, web apps run on CanvasKit or Skwasm. Both draw text with Skia inside a canvas, which means the browser's system font stack is not used the way it is in plain HTML. Skia can only use typefaces the Flutter engine has explicitly registered.
When the engine builds a text style, it does this (from the engine's canvaskit/text.dart):
fontFamilies = [fontFamily, ...fontFamilyFallback, ...globalFontFallbacks]
So a custom fontFamily like Inter or Poppins does not disable the built-in fallback chain — globalFontFallbacks is always appended. What it does do is guarantee that your primary font (which almost certainly has zero CJK or Arabic coverage) fails to resolve those code points, handing the whole job to a chain that starts out containing exactly one entry: Roboto.
After layout, Skia reports unresolved code points back to the engine, which passes them to FallbackFontService. That service picks a Noto font using a coverage algorithm plus navigator.language, then downloads it at runtime over the network from https://fonts.gstatic.com/s/ (the default value of the fontFallbackBaseUrl engine config). On success it registers the typeface, appends the family to globalFontFallbacks, and triggers a relayout. On failure you get this in the console:
Could not find a set of Noto fonts to display all missing characters. Please add a font asset for the missing characters.
That one line is the whole story of flutter web chinese characters not showing.
The four ways this fails in production
- The tofu flash. The download is asynchronous and happens after the first layout. First paint is always tofu; how long it lasts depends on the size of the Noto slice.
- The CDN is unreachable.
fonts.gstatic.comis blocked or unreliable in mainland China and behind plenty of corporate proxies. The service retries 3 times with a 1s delay, then marks fonts permanently unavailable — permanent tofu for your Chinese users specifically. - Content-Security-Policy. The engine fetches font bytes with
fetch, so a strict CSP needsconnect-src https://fonts.gstatic.com, not justfont-src. Miss it and every request dies. - Han unification picks the wrong face. Because family selection is weighted by
navigator.language, a Japanese string viewed in a browser set tozh-CNcan be drawn with Simplified Chinese glyph shapes. It's not tofu, but 直 and 骨 look wrong to a native reader — the classicflutter canvaskit japanese font not renderingfollow-up complaint.
The fix for all four is the same: stop depending on a runtime download and bundle the coverage yourself.
Step 1: bundle Noto subsets as real assets
Download the static TTF or OTF files (not the webfont zip) for the scripts you actually ship — Noto Sans SC, Noto Sans TC, Noto Sans JP, Noto Sans KR, Noto Sans Arabic. Flutter's asset pipeline accepts .ttf, .otf, and .ttc; .woff and .woff2 are not supported, so you cannot reuse the sliced woff2 files the engine downloads.
flutter:
uses-material-design: true
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Regular.ttf
- asset: assets/fonts/Inter-Bold.ttf
weight: 700
- family: NotoSansSC
fonts:
- asset: assets/fonts/NotoSansSC-Regular.ttf
- asset: assets/fonts/NotoSansSC-Bold.ttf
weight: 700
- family: NotoSansJP
fonts:
- asset: assets/fonts/NotoSansJP-Regular.ttf
- family: NotoSansArabic
fonts:
- asset: assets/fonts/NotoSansArabic-Regular.ttf
Budget honestly: a full Noto Sans SC regular weight is several megabytes. Ship one weight per script and let Flutter synthesize bold, or subset with pyftsubset down to the code points your ARB files actually contain.
Step 2: put them in fontFamilyFallback, app-wide
ThemeData takes both fontFamily and fontFamilyFallback, and applies the fallback list across the whole TextTheme:
final theme = ThemeData(
useMaterial3: true,
fontFamily: 'Inter',
fontFamilyFallback: const <String>[
'NotoSansSC',
'NotoSansJP',
'NotoSansArabic',
],
);
Because your fallbacks are inserted before globalFontFallbacks, resolution now succeeds locally and no network request is ever made. That alone kills flutter web tofu boxes font fallback for every script in the list.
Step 3: reorder per locale so Han unification is correct
A static list has a fixed priority, so Japanese text will pick up SC glyph shapes if NotoSansSC comes first. Reorder the chain from the active locale:
List<String> fallbacksFor(Locale locale) {
final chain = <String>[];
switch (locale.languageCode) {
case 'zh':
final hant = locale.scriptCode == 'Hant' ||
locale.countryCode == 'TW' ||
locale.countryCode == 'HK';
chain.add(hant ? 'NotoSansTC' : 'NotoSansSC');
case 'ja':
chain.add('NotoSansJP');
case 'ko':
chain.add('NotoSansKR');
case 'ar' || 'fa' || 'ur':
chain.add('NotoSansArabic');
}
// Everything else, as a last resort.
chain.addAll(const ['NotoSansSC', 'NotoSansJP', 'NotoSansArabic']);
return chain.toSet().toList();
}
Apply it below Localizations using MaterialApp.builder, where Localizations.localeOf(context) is available:
MaterialApp(
theme: theme,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) {
final locale = Localizations.localeOf(context);
final base = Theme.of(context);
final fallback = fallbacksFor(locale);
return Theme(
data: base.copyWith(
textTheme: base.textTheme.apply(fontFamilyFallback: fallback),
primaryTextTheme:
base.primaryTextTheme.apply(fontFamilyFallback: fallback),
),
child: child!,
);
},
)
Switching locale rebuilds the theme, so the glyph shapes change with the language — no restart.
Optional: self-host the remaining fallbacks
Even with a bundled chain, anything you didn't bundle (emoji, Cyrillic-adjacent scripts, symbols) still goes to Google's CDN. If that's a privacy, CSP, or China-availability problem, mirror the Noto files onto your own origin and point the engine at it:
_flutter.loader.load({
config: {
fontFallbackBaseUrl: "/assets/noto/",
},
});
fontFallbackBaseUrl is a documented engine config field, and it's resolved against the same relative paths the engine already uses. Note that flutter build web --no-web-resources-cdn self-hosts CanvasKit — it does not cover fallback fonts.
What still breaks
- Supplementary-plane Han. Noto Sans SC/TC/JP cover the common BMP blocks. Rare ideographs from CJK Extension B and beyond (U+20000+) aren't in the standard files, so a bundled-only chain still tofus on them. You need one of the very large full CJK OTFs, or you accept the CDN path for those code points.
- Emoji. The engine fetches
Noto Color Emojias sliced.woff2, a formatpubspec.yamlcan't declare. Bundling means the roughly 10 MBNotoColorEmoji.ttf. Most teams let emoji download or self-host it. google_fontsdoesn't save you.GoogleFonts.notoSansSc()fetches at runtime by default — same network dependency, same failure modes. Bundle or setGoogleFonts.config.allowRuntimeFetching = false.TextStyleoverrides. Any widget that setsfontFamily:directly withoutfontFamilyFallback:re-creates the bug locally. Grep for it.
Verify it in 60 seconds
Build, serve, open DevTools, throttle to Offline after first paint, and switch to zh/ja/ar. If glyphs still render and the Network tab shows zero requests to fonts.gstatic.com, you're done. If the console prints the "Could not find a set of Noto fonts" warning, a script is missing from your chain.
Keep the translations themselves honest
Font fallback fixes rendering. It doesn't tell you that your Arabic ARB dropped the few plural category, or that a locale is missing keys entirely — both of which look like bugs long after the boxes are gone. FlutterLocalisation is an ARB editor and translation-management platform for exactly that: edit app_<locale>.arb files in a UI instead of raw JSON, manage many locales side by side, and get ICU plural-syntax validation that flags a locale missing a plural category its language actually needs.
More Flutter i18n walkthroughs are on the blog, and plans are on the pricing page.