DEV Community

Zerrin Arslan
Zerrin Arslan

Posted on

I stopped loading fonts from Google's CDN — here's the self-hosting setup that stuck

For years I just dropped the Google Fonts <link> in my <head> and moved on. One line, done, who cares. Right?

Then I was staring at a Lighthouse report for a landing page that felt sluggish, and there it was in the waterfall: a render-blocking hop to fonts.googleapis.com, then a second one to fonts.gstatic.com for the actual file. Two extra connections — DNS lookup, TLS handshake, the whole dance — before a single character painted. On my laptop? Invisible. On a mid-range Android over flaky mobile data? Very visible.

So I bit the bullet and self-hosted. Took an afternoon, and I've done it on every project since. Here's the setup I've landed on after a bunch of trial and error.

Get a WOFF2 (and only WOFF2)

WOFF2 is supported literally everywhere now and it's ~30% smaller than the old WOFF. You don't need the .ttf, .eot, .svg fallback zoo from 2014 anymore. Just ship WOFF2.

If you've got a .ttf/.otf, convert it with fonttools:

pip install fonttools brotli
fonttools ttLib.woff2 compress MyFont.ttf   # -> MyFont.woff2
Enter fullscreen mode Exit fullscreen mode

Honestly I got tired of doing the CLI thing for one-off fonts, so I ended up building a little browser tool for it — FontBoxDL's webfont generator — drop a TTF/OTF in, get WOFF2 + a @font-face block back, no install. Use whatever's less friction for you.

The @font-face block

@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
Enter fullscreen mode Exit fullscreen mode

The line that matters most here is font-display: swap. It tells the browser "show the fallback text immediately, swap in the real font when it arrives." Without it you get FOIT — invisible text while the font loads — which is a worse experience than a quick font flash, basically every time.

Preload the one font that matters

Drop this in your <head>:

<link rel="preload" href="/fonts/myfont.woff2" as="font" type="font/woff2" crossorigin>
Enter fullscreen mode Exit fullscreen mode

Two things people get wrong here, me included:

  • Don't preload everything. Preload only the font(s) you actually use above the fold. If you preload six weights, you've just created a different bottleneck.
  • Don't forget crossorigin. I once left it off and spent a solid 20 minutes confused about why the font was being downloaded twice. Fonts are always fetched in CORS mode, so the preload has to match or the browser throws your preloaded copy away.

Subset if you care about bytes

A full font ships thousands of glyphs you will never render. If your site is English/Latin, subsetting can knock off 70%+:

pyftsubset MyFont.ttf \
  --unicodes="U+0000-00FF" \
  --flavor=woff2 \
  --output-file=myfont.subset.woff2
Enter fullscreen mode Exit fullscreen mode

I don't always bother for a single light body font, but for display/heading fonts it's an easy win.

Cache it like it'll never change (because it won't)

Fingerprint the filename, then cache forever:

Cache-Control: public, max-age=31536000, immutable
Enter fullscreen mode Exit fullscreen mode

Don't skip the fallback stack

Even with swap, give it a real fallback so the first paint looks intentional and the layout doesn't lurch when the swap happens:

body {
  font-family: "MyFont", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. Convert → @font-face with swap → preload the critical one (with crossorigin!) → cache hard. It's maybe an hour of work and you get a real LCP/CLS improvement plus one fewer third party in your critical path.

Anyone still on the Google CDN <link> for production — what's keeping you there? Genuinely curious if there's a case I'm missing.

Top comments (0)