Font Loading on Flutter Web: Assets, FOUT, and First Paint
How fonts actually load in a Flutter web build, why text flashes in a fallback face, and how to bundle or preload brand fonts for a fast first paint.
Published on • November 11, 2026
AI Assistant

Font loading on Flutter web has two layers that people tend to conflate: the Dart API that names a font family, and the browser pipeline that fetches the bytes and swaps them in. Get the layers right and your brand typeface is present at first paint; get them wrong and every visitor watches the headline re-render in Arial before it snaps into place. This post walks the pipeline and the fixes.
How fonts reach a Flutter web build
Declare fonts in pubspec.yaml under flutter: fonts:, with a family name and a list of assets, each carrying the weight and style it represents:
flutter:
fonts:
- family: Raleway
fonts:
- asset: fonts/Raleway-Regular.ttf
- asset: fonts/Raleway-Bold.ttf
weight: 700
- asset: fonts/Raleway-Italic.ttf
style: italic
Flutter accepts .ttf, .otf, and .ttc files; .woff and .woff2 are not supported on desktop platforms, so a web build ships the same full-size files as mobile — they go into the asset bundle, get an entry in the asset manifest, and are fetched over HTTP by the browser like any other asset. Flutter does not infer weight from a filename: if you never declare weight: 700, the engine fakes bold by thickening the regular outlines, which looks wrong next to the real thing.
On web, that declaration ends up in AssetManifest.bin inside build/web/, and the engine requests the file the first time the family is actually used — a bundled font is one more asset request, not a magic preload. If you already hold the bytes, FontLoader registers a family at runtime:
final loader = FontLoader('BrandSans')
..addFont(rootBundle.load('fonts/BrandSans-Regular.ttf'))
..addFont(rootBundle.load('fonts/BrandSans-Bold.ttf'));
await loader.load(); // subsequent Text widgets resolve BrandSans
Keep the family count honest: every declared weight is a separate file in the bundle, so ship only the weights you actually render — a four-weight family is four requests on first use.
Why you see a flash of fallback text
Flutter web renders text on a canvas (CanvasKit draws glyphs via WebGL; Skwasm does it in Wasm), so the browser’s own @font-face machinery never runs — but the underlying problem is identical to the classic FOUT: the first frames are painted before the font bytes have arrived from the server. A first-time visitor therefore sees the fallback face, then a swap; a repeat visitor usually sees nothing, because the bytes are in the HTTP cache. The same is true of google_fonts, which fetches files from fonts.google.com at runtime over HTTPS on first use and caches them on disk — the very first render of a family is always a fallback render.
That matters for more than aesthetics: text is usually above the fold, and a font swap re-lays-out the headline, which shows up as a layout shift and delays how finished the first paint looks. The browser’s own tooling for this problem is the CSS Font Loading API (document.fonts.ready, FontFace.status), which tells you exactly when drawing with a face is safe — useful when you drive any canvas text outside Flutter’s widget layer:
const face = new FontFace('BrandSans', 'url(/fonts/BrandSans-Regular.ttf)');
document.fonts.add(face);
await face.load();
await document.fonts.ready; // every face resolved, layout complete
One caveat: Flutter’s own bundled families never appear in document.fonts — the engine fetches them through the asset bundle, not through @font-face, so the page’s FontFaceSet only ever holds faces you registered yourself. To watch Flutter’s fonts arrive, use the Network and Performance panels instead.
Bundle brand fonts as assets for web
For fonts you control, bundling is the fix: the bytes ship with the build, arrive with main.dart.js’s asset manifest, and are ready before the first frame that needs them. Put the files in fonts/, declare them as above, and — if you arrived via google_fonts — drop the matching files into an assets folder, because the package prefers local files over HTTP fetches automatically.
// Only needed if you keep runtime fetching enabled anywhere:
void main() {
GoogleFonts.config.allowRuntimeFetching = false; // fail fast, no flash
runApp(const MyApp());
}
Turning off runtime fetching means a missing family throws instead of silently rendering a fallback — exactly the behavior you want in production, where a silent swap is worse than a loud error. Remember that bundled fonts carry their license too: register the OFL.txt (or equivalent) through LicenseRegistry so it appears on your licenses screen.
Bundling also removes a dependency from the critical path: the bytes travel with your own origin and your own cache policy instead of depending on fonts.google.com being reachable, which matters for intranet and offline-first deployments.
Registering the bundled font’s license takes four lines and keeps the licenses screen honest:
LicenseRegistry.addLicense(() async* {
final text = await rootBundle.loadString('fonts/OFL.txt');
yield LicenseEntryWithLineBreaks(['Raleway'], text);
});
Even with bundled bytes, the first frame can paint before the engine has parsed the file — and a family declared nowhere falls back to the engine’s default face with no warning at all. Declare a fallback chain so the placeholder at least holds a similar width, which is what keeps layout shift low:
ThemeData(
fontFamily: 'Raleway',
fontFamilyFallback: const ['Helvetica Neue', 'Arial', 'sans-serif'],
);
// per widget: Text('...', style: TextStyle(fontFamilyFallback: [...]))
A missing face then degrades to a plausible sans-serif instead of an engine default that reflows every headline when, or if, the real bytes arrive.
Tame google_fonts where you keep it
If some families stay on runtime fetch — a long tail of styles you will never bundle — reduce the damage. Import google_fonts_lite.dart instead of google_fonts.dart so the compiler tree-shakes the generated methods, and gate rendering on GoogleFonts.pendingFonts() so the first frame waits for the in-flight downloads instead of racing them:
FutureBuilder(
future: GoogleFonts.pendingFonts(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox.shrink(); // or your skeleton
}
return Text('Headline', style: GoogleFonts.montserrat(fontSize: 48));
},
)
Also keep the system-font fallback stack sane (fontFamilyFallback) so that while the fetch is in flight — or if it fails outright — the placeholder text at least has a plausible width, which is what keeps cumulative layout shift low.
Measure first paint honestly
Verify with a cold cache, not an already-warm tab. In Chrome DevTools, disable cache, hard-reload, and watch the Network panel for the .ttf requests plus the asset-manifest entries Flutter issues; then check the Performance panel for when the first frame that contains your headline actually paints. Test three states: first visit (bytes from network), repeat visit (bytes from HTTP cache), and offline (bytes must still resolve, which is the case only if they are bundled). If the first visit shows a swap and the other two do not, you have a bundling problem; if all three swap, you have a declaration problem — a family name in TextStyle that does not match the family: key in the pubspec.
If you ship both entrypoints with --wasm, measure the variant your target browser actually loads — the loader picks between the JS and Wasm builds, and the font timing belongs to the path that ran.
Ship checklist:
- Every family used in a
TextStylehas a matchingfamily:key inpubspec.yaml - Only the weights you actually render are declared — each one is a file in the bundle
- Production builds set
GoogleFonts.config.allowRuntimeFetching = false - First paint checked with DevTools cache disabled, on a cold browser profile
- A fallback chain is declared, so a missing face degrades instead of reflowing
- Font licenses are registered through
LicenseRegistry - The
google_fontslong tail is measured, not assumed — every runtime fetch is a swap on first use - Font requests confirmed in the Network panel — Flutter’s families never appear in
document.fonts
Takeaway: Bundle brand fonts as declared assets so they ship with the build, disable google_fonts runtime fetching in production, gate late fetches behind pendingFonts(), and always test first paint with a cold cache.