All posts

The font optimisation I measured wrong

, 5 min read

I self-hosted this site’s three fonts and reported a 65% reduction in font bytes — roughly 690 kB down to 240 kB. I had a hash check to prove it.

The reduction was zero. Not smaller than I thought. Zero. A page requesting the latin subset downloaded 240,320 bytes from Google, and downloads 240,320 bytes from my own origin now. Byte for byte identical.

The reason I believed otherwise is a better story than the optimisation would have been.

What I thought I’d found

Ask Google Fonts for three families at several weights and it returns a stylesheet with a lot of @font-face blocks. For my request — Bricolage Grotesque at 500/600/700, Newsreader at 400/500/600 plus an italic, JetBrains Mono at 400/500 — it returns 33, of which 18 cover the latin and latin-ext subsets I actually wanted.

I downloaded the file behind each of those 18 blocks, naming each one after the family and weight that declared it: bricolage-grotesque-500-latin.woff2, bricolage-grotesque-600-latin.woff2, and so on. Eighteen files, 1.17 MB on disk.

Then I hashed them:

ad33cbc6d23292050f1f2010848ab619  bricolage-grotesque-500-latin.woff2
ad33cbc6d23292050f1f2010848ab619  bricolage-grotesque-600-latin.woff2
ad33cbc6d23292050f1f2010848ab619  bricolage-grotesque-700-latin.woff2

Identical. All three weights, the same bytes — because Bricolage Grotesque is a variable font, and the file contains the whole weight axis. Google declares one @font-face per weight you asked for, but there is only one font behind them.

That is all true, and it is genuinely useful: it means I can declare one face with font-weight: 500 700 instead of three. Eighteen files became eight.

The false step was the next one, and I made it without noticing I’d made it. Eighteen files became eight, therefore — I reasoned — a browser used to download eighteen and now downloads eight. Three identical copies of Bricolage, avoided.

The check that couldn’t fail

Here is the thing I should have looked at, and didn’t. Not the files. The URLs.

18 @font-face blocks -> 8 distinct URLs

  3 declarations share one URL: Bricolage 500 latin, 600 latin, 700 latin
  3 declarations share one URL: Newsreader 400 latin, 500 latin, 600 latin
  2 declarations share one URL: JetBrains Mono 400 latin, 500 latin

Google never served the same font three times. It served one URL, referenced three times. Three @font-face blocks pointing at one URL produce one HTTP request and one cache entry, because that is what a URL is.

The duplication was mine. My script iterated over declarations rather than over URLs, so it fetched the same URL three times and wrote the response to three different filenames. Then I hashed those files and discovered — with some satisfaction — that they were identical.

Of course they were identical. They were the same file. I had downloaded it three times and given it three names.

The hash assertion felt like rigour. It was a check that could not fail. It proved my downloader was deterministic, which was never in question, and I read it as proving something about Google’s CDN. A test that cannot come out the other way is not evidence, however satisfying it is to run.

The check that would have caught it takes one pass over the same stylesheet: map each declaration to its URL and count the distinct ones. Eighteen declarations, eight URLs. I had the file open the whole time.

The real reason to self-host

None of this makes self-hosting wrong. It relocates the benefit.

Loading fonts from Google costs two third-party origins before the first character can be painted:

  1. DNS lookup and TLS handshake to fonts.googleapis.com, for the stylesheet — which is render-blocking, so nothing paints until it lands.
  2. DNS lookup and TLS handshake to fonts.gstatic.com, for the font files the stylesheet then reveals.

That is a serial dependency. The browser cannot discover the font URLs until the CSS arrives, and the CSS lives on a different host from the fonts, so neither connection can be warmed by the other.

This site’s HTML arrives from a Cloudflare edge node with a median time-to-first-byte of 91 ms over eight cache-busted requests from Kathmandu — 78 ms at best, 121 ms at worst. Against that, two handshakes to two unrelated hosts are not a rounding error; they are the dominant cost before first paint. Self-hosting removes both, and the fonts become same-origin requests on a connection that is already open.

That figure is worth stating as a range and a sample size, for a reason this post has already earned: I originally wrote “about 38 ms” here, from a single measurement taken on a different day. One sample of a network is an anecdote. Eight is barely better, but at least it admits a spread.

It is a latency win. I sold it as a bandwidth win, and there wasn’t one.

The distinction matters because it changes who benefits. A bandwidth saving helps people on metered or slow connections. A round-trip saving helps people who are far from the origin — which, for a site served from Kathmandu, is most of the audience I care about. It is arguably the better win. It just isn’t the one I claimed.

What I actually changed

The eight-file consolidation stayed, because declaring one face per family with a weight range is a clearer statement than declaring three that happen to resolve to the same place. It saves nothing at runtime and I no longer pretend it does.

I also dropped the cyrillic, greek and vietnamese subsets. That sounds like it should save something, and it doesn’t either: unicode-range means a browser only fetches a subset when a character on the page needs it. Nobody was downloading the Greek. Dropping it saves disk in my repo and nothing on the wire.

So the honest summary of a change I originally described as a 65% reduction: two origins removed from the critical path, one render-blocking stylesheet removed, and the same number of bytes.

The part I’d tell myself

I have a rule on this site that no claim ships without a number I measured. This number was measured. I ran the command, I read the output, the output was real.

It was measured against the wrong thing, which no amount of measuring detects. The failure mode isn’t laziness — it’s a check that agrees with you by construction, run with enthusiasm, producing a result you were already expecting.

So the rule needs a second half, and this is the version I use now: before trusting a measurement, ask what result would have proved you wrong, and confirm the check was capable of producing it. Hashing files I had downloaded myself could only ever tell me about my downloader. The URLs could have contradicted me in a single line, and that is exactly why I didn’t look at them.