We stopped loading fonts from Google¶
Two lines in our page head were doing more damage than they looked like.
What was there¶
Every page carried the usual snippet:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:..." rel="stylesheet">
It is the copy-paste every project starts with, and it is wrong in two separate ways that have nothing to do with each other.
The slow way¶
That third line is a render-blocking stylesheet on another origin. The browser has to resolve a DNS name, complete a TLS handshake, fetch a small CSS file, and only then discover which font files it needs. Nothing paints until that finishes.
Lighthouse put it at roughly 660 ms of render-blocking work. On our landing
page, first contentful paint and largest contentful paint were the same moment -
the page was not slow because of images or JavaScript, it was slow because it was
waiting on a stylesheet from someone else's server. After the change, the
render-blocking-resources audit simply passes.
The illegal way¶
The second problem is not a performance problem. Loading a font from Google's CDN transmits the visitor's IP address to Google, before the visitor has agreed to anything. For a company operating from Germany, that is not a grey area: LG München I found it an unlawful transfer under the GDPR in 3 O 17493/20 on 20 January 2022, and awarded damages against the site operator.
We are a Leipzig UG that had already done the work for EN 301 549 / BITV 2.0 accessibility. Leaving a third-party font request in place next to that was not a defensible combination.
What replaced it¶
The fonts are vendored the same way our JavaScript libraries already were: pinned npm tarballs, verified against a SHA-256 sum at build time, extracted into the image, and served from our own origin.
FONTS = {
"inter": (
"https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
"02034af8d41dcc67ac8eab88f642e129bb8a2e8922abf30229c32725f54d8fd6",
"Inter",
{"latin": [300, 400, 500, 600], "latin-ext": [300, 400, 500, 600]},
),
...
}
Four details that mattered more than expected:
Only the weights we use, only the latin subsets. German needs nothing beyond
latin - ä, ö, ü and ß all live under U+00C0-00FF - and latin-ext is kept for
names that do. Twenty files, about 390 KB on disk; a visitor downloads the two or
three faces their page actually renders.
The unicode-range comes from the upstream package, not from us. Each
@font-face rule is generated by reading the ranges out of the font package's
own stylesheet. Typing them by hand works right up until a subset's coverage
changes upstream and nobody notices.
The generated stylesheet is inlined, not linked. A linked stylesheet would be one more render-blocking request for three kilobytes - solving the problem by moving it.
Two faces are preloaded: the body face the largest paint is made of, and the
display face the h1 is set in. Font preloads need crossorigin even when the
font is same-origin, because fonts are fetched in CORS mode. Without it the
browser downloads the file twice, which is a worse outcome than not preloading.
The part we did not expect¶
Our Content-Security-Policy now has no external origin at all:
default-src 'self'; script-src 'self' 'nonce-...'; style-src 'self' 'unsafe-inline';
font-src 'self' data:; img-src 'self' data:; connect-src 'self';
Google Fonts had been the single exception. With it gone, our page smoke test - which loads every page in a headless browser - allows zero third-party hosts, and fails the build on any off-origin request. "Nothing leaves your origin" went from a description to something a test can prove, on every page, on every commit.
That is the part worth stealing even if you do not care about the milliseconds.