Skip to content

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.