devShakib

Self-Hosting Fonts in Flutter Web: Dropping the google_fonts Package

Self host Inter, Space Grotesk and JetBrains Mono in Flutter web: the fonttools subsetting pipeline, the variable font axis trap, and why ttf beats woff2 here.

Load my portfolio on a cold cache and, until a few weeks ago, you'd see the hero headline paint in the wrong typeface for a beat and then snap into Space Grotesk. Classic FOUT. Except this wasn't a CSS @font-face swap — it's a Flutter web app, so what you were actually watching was the google_fonts package booting, hitting fonts.googleapis.com for a stylesheet, following that to fonts.gstatic.com for the actual bytes, and then telling Skia to re-lay-out the frame. Two extra hosts, two DNS lookups, two TLS handshakes, and a re-paint — for text I could have shipped in the bundle.

google_fonts is a good package solving a real problem: it makes "I want Inter" a one-liner and keeps a thousand font files out of your repo. On mobile, where you're fetching once and caching to the filesystem, that trade is fine. On web it's the wrong default, because the web already has a cold-start problem and you're adding third-party round trips to the critical path of first paint.

So I ripped it out. Three families — Inter, Space Grotesk, JetBrains Mono — subset to latin, cut down to the weights I actually use, bundled as assets, declared in pubspec.yaml, cached for a year. This post is the whole pipeline: the fonttools script and the variable-font gotcha that broke it twice, why you want .ttf and not .woff2 in a Flutter web app specifically, how to choose weights without shipping nine of them, the cache headers, how to measure whether it helped, and the privacy argument that would justify the change on its own.

What google_fonts actually does at runtime

Worth being precise about, because the package's API makes it look like a compile-time thing:

static TextStyle display(double size) => GoogleFonts.spaceGrotesk(      fontSize: size,      fontWeight: FontWeight.w700,    );

That call returns a TextStyle immediately with a font family name that doesn't exist yet. In the background the package resolves the family against a bundled manifest, issues an HTTP request to Google's CDN, and on success hands the bytes to Flutter's FontLoader, which registers the family and marks the text layout dirty. Until that completes, your text renders in whatever fallback the platform picks.

On web the sequence costs you, in order: a DNS lookup for fonts.googleapis.com, a TLS handshake, a CSS response, a DNS lookup for fonts.gstatic.com, another TLS handshake, then N font files — one per family-and-weight combination you touched. On a warm 5G connection that's maybe 200ms. On a mid-range Android on hotel wifi it's comfortably a second, and every millisecond of it lands after your app has already booted, so the user watches it happen.

The standard mitigation is <link rel="preconnect"> in index.html, and I had both of them in there:

<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Preconnect warms the handshake but doesn't remove the requests, and it costs you two connections you're now holding open on a page that already needs Firestore and Firebase Storage. Deleting both lines was part of the change.

The package does offer an escape hatch — bundle the font files yourself and set GoogleFonts.config.allowRuntimeFetching = false, at which point it resolves from assets. If you do that, though, you're already maintaining font files in your repo and keeping a dependency whose only remaining job is mapping a method name to a family string. At that point TextStyle(fontFamily: 'Inter') does the same work with nothing in pubspec.yaml.

Subsetting with fonttools

A full Inter variable font is roughly 800KB. You don't need most of it: you don't need Cyrillic, you don't need Vietnamese, and if you ship a variable font to Flutter web you're paying for an axis range that CanvasKit will only ever sample at four points.

The tool is fonttools (pip install fonttools brotli). The pipeline per output file is: instantiate a static weight from the variable font, subset the character set, save as TTF.

The variable font gotcha that cost me an hour

Inter is a variable font with two axes: wght (100–900) and opsz (14–32, the optical-size axis that subtly tightens spacing and thins strokes at display sizes). The obvious first attempt is to pin the weight and move on:

instancer.instantiateVariableFont(font, {"wght": 600}, inplace=True)

That does not produce a static font. It produces a partial variable font — wght is pinned, opsz is still floating, fvar still exists — and when you then hand it to the subsetter, it walks the variation tables that still reference axes the instancer has been rearranging and dies with a KeyError on an axis tag. The error points at the subsetter, which is why I spent the first twenty minutes convinced my unicode ranges were malformed.

Pin every axis the font declares. Once all axes have a fixed value, instantiateVariableFont drops fvar, gvar and avar entirely and hands you a genuine static instance that the subsetter is happy to chew on:

from fontTools.ttLib import TTFontfrom fontTools.varLib import instancerfrom fontTools import subsetdef static_weight(src, out, weight, axes):    font = TTFont(src)    # Inter declares BOTH wght and opsz. Pin every axis — leaving one    # floating produces a partial VF and the subsetter dies with a    # KeyError on the leftover axis records.    instancer.instantiateVariableFont(        font, {**axes, "wght": weight},        inplace=True, updateFontNames=True,    )    ...static_weight("Inter[opsz,wght].ttf", "Inter-SemiBold.ttf", 600, {"opsz": 14})

The generalisable rule: read the axes out of the font rather than assuming, because the next family you subset will have a different set. TTFont(src)["fvar"].axes gives you the tags, and asserting that your pin dictionary covers all of them turns a confusing KeyError into a clear failure at the top of the script.

Pick your opsz value deliberately, by the way. I pin 14 for body text — the text-optimised end, slightly looser and sturdier. If you're generating a display-only cut you'd want the high end. It's a real design decision that the variable font was making dynamically for you and now isn't.

Which characters to keep

Google Fonts' own latin subset is a sensible target, and it's worth copying their range verbatim rather than inventing one, because they've already worked out which punctuation and symbols real pages actually hit:

LATIN = (    "U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,"    "U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,"    "U+2193,U+2212,U+2215,U+FEFF,U+FFFD")options = subset.Options(    layout_features=["kern", "liga", "clig", "calt", "ccmp", "locl", "mark", "mkmk"],    notdef_outline=True,    recalc_bounds=True,)sub = subset.Subsetter(options=options)sub.populate(unicodes=subset.parse_unicodes(LATIN))sub.subset(font)font.flavor = None          # stay TTF — see belowfont.save(out)

U+2000-206F is the block that gets forgotten and then noticed: en dash, em dash, curly quotes, the non-breaking hyphen, the ellipsis. Drop it and every typographically correct dash on your site renders as tofu. U+FFFD is the replacement character itself, which you want present so that a genuinely missing glyph looks like a missing glyph rather than nothing.

Keep the layout features. Dropping kern to save a few kilobytes is a false economy — you'll get visibly loose "AV" and "To" pairs across every heading on the site, which is precisely the thing you self-hosted a nice typeface to avoid.

Choosing weights: ship four, not nine

Inter publishes nine weights. I ship four: 400, 500, 600, 700. Space Grotesk the same four. JetBrains Mono gets 400 and 700, because code is either code or emphasised code.

Ten files, and here is what they cost:

Inter-Regular.ttf         90.7 KB raw    33.5 KB brotliInter-Medium.ttf          90.7 KB raw    34.3 KB brotliInter-SemiBold.ttf        90.9 KB raw    34.1 KB brotliInter-Bold.ttf            91.0 KB raw    34.5 KB brotliSpaceGrotesk-*.ttf (x4)   40.1 KB raw    ~17.5 KB brotli eachJetBrainsMono-*.ttf (x2)  53.7 KB raw    ~24.0 KB brotli each                          ------------------------------total                      616 KB raw     248 KB brotli

Every extra weight is another 17–35KB brotli and another request. That's the argument against shipping the full range "just in case": you will use three of them and pay for nine.

The thing to know before you trim is how Flutter resolves a weight it doesn't have. It matches the nearest declared weight in the family — it does not synthesise an intermediate, and on web it will not fall back to a different family. So a FontWeight.w800 heading in your theme silently renders at 700 once you stop shipping 800. That's usually fine and occasionally not, and you want to find out during the migration rather than from a screenshot three weeks later. I grepped the codebase for FontWeight.w before choosing the set, which took two minutes and told me exactly which four I needed.

One more caveat that catches people: the family name Flutter uses is the one you write in pubspec.yaml, not the name inside the font's name table. You can call the file whatever you like and declare family: Inter; Flutter matches on the pubspec key. Which also means a typo there fails silently — Flutter falls back to the platform default and gives you no warning at all.

Why .ttf and not .woff2 in Flutter web

This is the counter-intuitive one, because on a normal web page .woff2 is unambiguously correct — it's the same outlines with brotli-style compression baked into the container, and every browser since 2016 reads it.

Flutter web is not a normal web page. Under CanvasKit and skwasm, text isn't rendered by the browser's font stack at all: Flutter fetches the asset bytes itself and hands them to Skia, which parses them through FreeType. Skia's font manager reads TTF and OTF. It does not decompress WOFF2. Feed it a .woff2 and you don't get an exception you can catch — you get a family that silently never registers and text that renders in the fallback forever.

So font.flavor = None in the subsetting script is deliberate: save plain TTF and let the transport do the compression that WOFF2 would have done in the container. That's what the brotli column above is measuring, and it lands in the same ballpark — 90.7KB of TTF compresses to 33.5KB on the wire, which is roughly what the equivalent WOFF2 would have been. You lose nothing except the ability to look at ls -la and feel good.

The corollary is that your server must actually be negotiating brotli on .ttf, or you're shipping 616KB where you meant to ship 248KB. Firebase Hosting does this automatically. If you're on a CDN you configured yourself, check the content-encoding response header on a font request before you believe the numbers.

Wiring it up

The pubspec.yaml half is unremarkable, which is the point:

flutter:  fonts:    - family: Inter      fonts:        - asset: assets/fonts/Inter-Regular.ttf          weight: 400        - asset: assets/fonts/Inter-Medium.ttf          weight: 500        - asset: assets/fonts/Inter-SemiBold.ttf          weight: 600        - asset: assets/fonts/Inter-Bold.ttf          weight: 700    - family: SpaceGrotesk      fonts:        - asset: assets/fonts/SpaceGrotesk-Regular.ttf          weight: 400        # ...500, 600, 700    - family: JetBrainsMono      fonts:        - asset: assets/fonts/JetBrainsMono-Regular.ttf          weight: 400        - asset: assets/fonts/JetBrainsMono-Bold.ttf          weight: 700

And every call site becomes a plain TextStyle:

static TextStyle display(double size) => TextStyle(      fontFamily: 'SpaceGrotesk',      fontSize: size,      fontWeight: FontWeight.w700,      letterSpacing: -1.5,      height: 1.1,    );

If your typography is centralised in one file, this is a find-and-replace. Mine touched seven files, because a few widgets had reached for GoogleFonts.jetBrainsMono directly instead of going through AppTypography — which is its own small lesson about letting a font package leak into widget code.

Then delete the dependency from pubspec.yaml, delete the two preconnects from index.html, and run flutter analyze to catch the imports you missed.

Cache headers

Fonts are the ideal immutable asset: the bytes for Inter-SemiBold.ttf will not change unless I regenerate it, and if I do I'll be changing the design anyway. My firebase.json:

{ "source": "**/*.@(woff2|otf|ttf|wasm)",  "headers": [{ "key": "Cache-Control",                "value": "public, max-age=31536000, immutable" }] }

A year, immutable, no revalidation. This is the header I won't put on my JS bundles, because Flutter emits those without content hashes — but fonts genuinely earn it.

If you want to go further, preload the one weight that's on the critical path. Note the path shape, which surprises everyone the first time: Flutter serves bundled assets under assets/, and your own assets/fonts/ folder lands at assets/assets/fonts/:

<link rel="preload" as="font" type="font/ttf" crossorigin      href="assets/assets/fonts/Inter-Regular.ttf">

Preload one or two files at most. Preloading all ten makes them compete with canvaskit.wasm for bandwidth on exactly the connection you're trying to keep clear.

Measuring whether it helped

Three numbers, in order of how much I trust them.

Request count and origin count on a cold load. Open DevTools, disable cache, filter to Font. Before: requests to two Google origins. After: same-origin requests only, multiplexed over the HTTP/2 connection that's already open because it served index.html. This is the win and it's binary — you can see it in one screenshot.

Transferred bytes. 248KB brotli for ten faces. Compare it honestly against what google_fonts was fetching, which for my three families at those weights was in the same range — because it's the same font data. Self-hosting is not primarily a bytes win; it's a latency and round-trip win. Anyone telling you self-hosting halves your font payload is comparing against an unsubsetted baseline.

Time to the text being correct. Not first paint — first correct paint. With the runtime fetch there are two paints: fallback, then Inter. With bundled assets Flutter registers the families during startup, alongside the asset manifest it's already loading, so there's one paint and it's the right one. That's the thing users perceive, and it's the reason this change felt bigger than the numbers suggest.

While you're in there, one adjacent freebie: MaterialIcons-Regular.otf in my release build is 60.8KB raw, 29.6KB brotli, because --tree-shake-icons is on by default in release and strips every glyph the app doesn't reference. It's the same idea as subsetting, already done for you — but only if you don't defeat it by constructing IconData dynamically from a code point.

The privacy argument

Even with the performance numbers set aside, there's a reason to stop calling Google on every page load: every request to fonts.gstatic.com sends the visitor's IP address, user agent and referring page to a third party, before the visitor has agreed to anything.

In January 2022 a Munich regional court ruled that embedding Google Fonts dynamically transmitted a visitor's IP address to Google without a legal basis under GDPR, and awarded damages against the site operator. The amount was trivial — €100 — but it established the shape of the argument, and a wave of warning letters followed across Germany. The point isn't that anyone's coming for your portfolio. It's that a runtime font fetch is a third-party data transfer you can't put in a cookie banner, can't defer behind consent, and gain nothing operational from.

Self-hosting removes it entirely. No third-party origin in the font path, nothing to disclose in a privacy policy, nothing to explain to a client's legal team. For me that turned a performance nice-to-have into something I now do by default on every web build, and it's the same reason all 98 tools on my site run fully client-side: the fastest and most private request is the one you never make.

Key takeaways