CogniPrep's root layout loads three font families, from two different mechanisms:
import { GeistSans } from 'geist/font/sans'; import { GeistMono } from 'geist/font/mono'; import { Space_Grotesk } from 'next/font/google'; const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space-grotesk', display: 'swap', preload: true, // Preload to prevent font flash in header adjustFontFallback: true, }); All three variable classes go on <html>:
<html className={`${GeistSans.variable} ${GeistMono.variable} ${spaceGrotesk.variable}`}> and globals.css turns them into Tailwind v4 theme tokens:
@theme { --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, ...; --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, 'SF Mono', Menlo, ...; --font-brand: var(--font-space-grotesk), var(--font-geist-sans), ui-sans-serif, ...; } That reads like a tidy three-tier type system: body, code, brand. Here is what the browser is actually asked to fetch.
Three preloads, 163 KB, on every page
curl -s https://cogniprep.app/ | grep -o '<link rel="preload"[^>]*woff2[^>]*>' Three hits, and the sizes:
| File | Bytes | Family |
|---|---|---|
Geist_Variable-s.p.*.woff2 | 69,652 | Geist Sans, weights 100 to 900 |
GeistMono_Variable.p.*.woff2 | 71,368 | Geist Mono, weights 100 to 900 |
0c89a48fa5027cee-s.p.*.woff2 | 22,320 | Space Grotesk, latin subset |
163,340 bytes of woff2 at rel=preload, which is the browser's "fetch this before you need it" priority, on /, /pricing, /blogs, /games/<provider> and /login. I checked all five: identical three preloads, because the thing that triggers a next/font preload is the font being referenced by the layout that wraps the route, not a glyph being painted.
One of them paints nothing on most pages
The only way to use Geist Mono here is the font-mono utility, so counting it in the delivered HTML counts the font's entire job on that page:
for p in / /login /pricing /blogs /about /signup; do printf "%s %s\n" "$p" "$(curl -s https://cogniprep.app$p | grep -o 'font-mono' | wc -l)" done / 112 /login 0 /pricing 0 /blogs 0 /about 0 /signup 0 The homepage's 112 are one string counted twice: 56 in the rendered markup and the same 56 in the RSC payload that follows it, every one of them a two-digit index beside a provider name (<span class="font-mono text-xs text-white/30">01</span>). The other five pages use it zero times, and still pay 71,368 bytes of preload: 44% of the font budget on those pages, fetched at high priority, to render nothing at all.
Across the app, 40 files reference font-mono, and they are all the same genre of text: a user id on the profile page, a support ticket reference, an error digest in the error boundary, a kbd hint in the search box. Real uses, all of them, and none of them worth a preload on a sign-up form.
While I was in there: the token with no utility
Tailwind v4 generates a utility for every @theme key, so --font-brand should give us font-brand. Search the live stylesheet:
# the big stylesheet is 245,213 bytes grep -c '\.font-brand{' big.css # 0 grep -o '\-\-font-brand:[^;]*' big.css # --font-brand:var(--font-space-grotesk), var(--font-geist-sans), ui-sans-serif, ... The variable ships. The class does not, because Tailwind v4 only emits utilities it sees used, and all four places that want the brand font do this instead:
<span style={{ fontFamily: 'var(--font-brand)' }}>CogniPrep</span> Four components: the public footer, the dashboard header, the auth layout and the shared brand lockup. The inline style works, so nothing is broken, which is exactly why it survived. The cost is that a @theme entry that exists to generate a utility now exists to generate a variable that four files read by hand.
And the subset that is not a subset
We asked for subsets: ['latin']. The emitted CSS has three @font-face blocks for Space Grotesk:
| Unicode range | Bytes | Preloaded |
|---|---|---|
| latin | 22,320 | yes |
| latin-ext | 18,924 | no |
| vietnamese | 6,772 | no |
This is next/font/google behaving correctly and counter to how the option reads. subsets controls what gets a preload hint, not what gets declared. The other two faces sit in the stylesheet with their unicode-range and are fetched only if a character in that range is painted. For a site whose public copy is English, those two files are a rounding error that never downloads. Worth knowing before you file a bug about it.
One more detail from the same CSS, for anyone chasing layout shift: two of the three families get a metrics-adjusted fallback and one does not.
@font-face{font-family:GeistSans Fallback;src:local(Arial); ascent-override:94.56%;descent-override:27.76%;size-adjust:106.28%} @font-face{font-family:Space Grotesk Fallback;src:local(Arial); ascent-override:89.71%;descent-override:26.62%;size-adjust:109.69%} Geist Mono has no fallback face at all, just a stack ending in monospace. With font-display: swap on all three, a slow font fetch means sans and brand text swaps into a size-matched Arial and mono text swaps into whatever the OS calls monospace at its own metrics. For the amount of mono text on this site that is the right trade, and it is another argument for the font not being in the critical path.
What I would change
- Take Geist Mono out of the root layout. It is 71 KB preloaded for a user id and a ticket reference. Either declare
--font-monoas a plain system stack (ui-monospace, SFMono-Regular, Menlo, monospace), which is what the fallback already renders on a slow connection, or load it withpreload: falseso it is fetched when a page that uses it asks. - Measure preloads per page, not per config. Everything above came from one
grepagainst production HTML. The config file said "three fonts"; only production says "three fonts, on every route, before paint". - Leave the brand font alone. 22 KB for the wordmark on every page is real, but the wordmark is on every page, in the header and the footer, and the fallback metrics mean the swap does not move the layout. That one is paying for itself.
The general shape of this: preload on a font is a statement about the whole route group, and the route group is "everything the layout wraps". If a font is used by 1 page in 6, it does not belong in the layout that wraps all 6.
See it
Every number above is measurable from a terminal against the live site. Open DevTools, filter Network by Font, and load the homepage (three woff2, one of which renders those small provider numbers), then the login page and the pricing page, where the same three arrive and the monospace one has no character to draw. The wordmark in the header and footer of the about page is the Space Grotesk face, set by an inline style rather than a Tailwind class, and the blog index is the biggest document of the six I measured, at 674,168 bytes of HTML, if you want a page where 71 KB of unused font is not the largest thing in flight.

