A week ago I wrote about next/font/google breaking on an ampersand, which is how Notifio ended up with two woff2 files committed into the repository. The comment that survived that change is still at the top of the root layout:
// Self-hosted latin variable fonts (Google Fonts). next/font/google in Turbopack
// fails the build whenever Google serves a font URL containing "&".
const spaceGrotesk = localFont({
src: "./fonts/space-grotesk-latin.woff2",
variable: "--font-space-grotesk",
weight: "300 700",
display: "swap",
preload: true,
});
Two of those, Space Grotesk and DM Sans, plus Geist Mono from the geist package. Three typefaces, 59KB of woff2 in src/app/fonts/, nothing fetched from Google at build time or at request time. You can see all three preloaded on any page that is not a price page:
$ curl -s https://notifio.app/about | grep -o 'rel="preload"[^>]*\.woff2[^>]*'
rel="preload" href="/_next/static/media/GeistMono_Variable.p.3ms9vq719j3f8.woff2" as="font"
rel="preload" href="/_next/static/media/dm_sans_latin-s.p.2_mbdogr7ni8i.woff2" as="font"
rel="preload" href="/_next/static/media/space_grotesk_latin-s.p.2cyn07wtgehh0.woff2" as="font"
Three files, served from our own origin, hashed, immutable. The problem was solved. I believed that when I wrote it and it was true about the website.
It is not true about the repository. Today, while measuring something else entirely, I grepped for googleapis and got a hit.
The one file that still goes to Google
$ grep -rn "fonts.googleapis" server/src server/scripts
server/scripts/generate-og-image.mjs:48:<link rel="preconnect" href="https://fonts.googleapis.com">
server/scripts/generate-og-image.mjs:50:<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
That is the script that renders public/og-image.png, the card every social platform shows when somebody shares the site. I have written about how it works: it is a build-time Playwright screenshot, driven by the Chromium the desktop app workspace already has, because putting a satori render on the request path for an image that changes once a quarter is nonsense.
Look at the URL. There are two ampersands in it, one before each extra parameter. That is the character. That is the exact shape of URL that next/font/google could not get through Turbopack, which is the reason the two fonts it names are sitting in src/app/fonts/ as committed binaries in the first place.
The site stopped asking Google for Space Grotesk and DM Sans. The script that draws the picture of the site still asks Google for Space Grotesk and DM Sans, through the same ampersand, on every run.
The same file already knows the right answer
The funniest part is twelve lines above it. Geist Mono, the third typeface, is handled properly:
/** Inlined so Chromium never has to resolve a file:// URL for them. */
const monoFont = (weight, file) => {
const path = join(SERVER, "node_modules/geist/dist/fonts/geist-mono", file);
const b64 = readFileSync(path).toString("base64");
return `@font-face{font-family:'Geist Mono';font-weight:${weight};font-style:normal;src:url(data:font/woff2;base64,${b64}) format('woff2');}`;
};
${monoFont(300, "GeistMono-Light.woff2")}
${monoFont(400, "GeistMono-Regular.woff2")}
${monoFont(700, "GeistMono-Bold.woff2")}
Read from disk, base64, inlined into the document, no network. One function, five lines, already written, already working, with a comment explaining the reasoning. And then the next statement in the same template literal is a <link> to Google for the other two families.
So one script uses both strategies on the same brand, and the one it uses for the half that is already sitting in the repository is the network one. The two woff2 files it needs are 22,320 and 36,980 bytes, in this same package, two directories away.
Nothing in the script would tell you if it failed
This is the part that makes it worth writing down rather than just fixing.
await page.setContent(html, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: OUT, type: "png" });
Those two lines look like a guard against exactly this. They are not.
waitUntil: "networkidle" waits for the network to go quiet. A stylesheet request that fails, times out, or is refused makes the network quiet just as effectively as one that succeeds.
document.fonts.ready is worse, because it reads like an assertion. It is a promise that fulfils when the document has finished loading fonts and doing the layout that depends on them. Finished includes failed. A FontFace that errors still settles, the set still reaches a stable state, and the promise still resolves. It never rejects on a font that did not arrive.
So if Google is unreachable, or slow enough to lose the race, or decides my laptop has asked enough times today, the sequence is: request fails, network goes idle, fonts.ready resolves, screenshot is taken, PNG is written, console.log says wrote .../og-image.png (1200x630 @2x), exit code 0.
And what gets drawn is this, with the brand fonts swapped for whatever sans-serif means on the machine:
body{ font-family:'DM Sans',sans-serif; }
h1{ font-family:'Space Grotesk',sans-serif; font-weight:700; }
.wordmark{ font-family:'Geist Mono',monospace; font-weight:300; }
The wordmark and the pills would still be right, because Geist Mono is inlined. The headline and the body copy, which is most of the card, would silently become the system sans. It would not look broken. It would look like a card somebody designed slightly worse.
Then the PNG gets committed, because it is a committed artefact, and it stays wrong until the next person who regenerates it happens to look.
It worked, which is the only reason this is a near miss
I opened the committed file to check. The headline is Space Grotesk, the body copy is DM Sans, the wordmark is Geist Mono. Google answered on 14 September and the card is correct:
$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" https://notifio.app/og-image.png
200 214951
215KB of correct PNG. So there is no bug to report and no user affected. What there is, is a build-time dependency on a third-party CDN that exists for a few seconds a quarter, on one laptop, with no failure mode, producing an artefact that is committed and therefore never re-verified.
That is a worse shape than an outage. An outage tells you.
Three surfaces, three answers, and the one with none
While I was counting, I checked the third place a font decision lands. The emails:
$ grep -ho "font-family:[^;\"]*" server/src/lib/emails/*.ts | sort -u
font-family:'SF Mono','Consolas','Monaco',monospace
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif
Zero brand fonts. That one is correct and deliberate: an HTML email cannot carry a webfont with any confidence, so the templates use the system stack, and the activation token uses whatever monospace the reader has. Gmail would strip an @font-face and Outlook would do something stranger.
So the brand has three typefaces and three surfaces, and no two surfaces agree on how to get them:
| Surface | Space Grotesk | DM Sans | Geist Mono |
|---|---|---|---|
| The website | self-hosted woff2, preloaded | self-hosted woff2, preloaded | from the geist package |
| The social card | fetched from Google at render time | fetched from Google at render time | inlined from disk as base64 |
| The emails | none | none | none |
The middle row is the only one that touches the network, and it is the one that produces the image representing all of it.
The fix is calling a function that already exists
Generalise monoFont to take any path, point it at the two woff2 files the site already ships, delete the <link> and the two preconnects. The site's files are variable fonts covering 300 to 700, so one @font-face per family replaces the five static weights the Google URL asks for. The script gets shorter, loses its only network dependency, and the card starts being drawn from the exact bytes the website serves, which is the thing the script's own header comment claims it is for:
The image is generated rather than drawn by hand so it stays in step with the landing page: same three fonts.
Same three fonts. From two different places, one of which the landing page stopped using a week ago.
If you want to see the card the Google fetch produced while it was still working, it is at notifio.app/og-image.png, and the <link> that fetched it is still line 50.
Top comments (0)