Your stylesheet is
94% font data.
Inlining a webfont as a base64 data URI feels efficient — one request instead of two, no flash of unstyled text. It is a trap. The font is now inside a render-blocking file, it is a third larger than the binary, and nothing on the page paints until all of it arrives.
Three costs, and only one is obvious
1. Size. Base64 encodes three bytes as four characters — a flat 33% penalty before compression, and font binaries barely compress because they are already compressed. A 75KB woff2 becomes roughly 100KB of text that gzip can do very little with.
2. Render blocking. This is the expensive one. CSS is render-blocking by definition: the browser will not paint until it has the stylesheet. Put a font inside the stylesheet and you have made the font render-blocking too — where a linked font file would have downloaded in parallel while the page painted.
3. Caching. Fonts never change. Your CSS changes constantly. Weld them together and every one-line CSS tweak re-downloads every font, forever.
What we found on our own site
Our stylesheet was 138.5KB, of which 130.2KB — 94% — was two base64 font faces. The actual CSS was 8.3KB. Every visitor waited for 130KB of font data before a single pixel appeared, and it was not even being compressed, because of a separate nginx problem.
The font-display value that hides your text
Independent of inlining, check this one line. The font-display descriptor decides what
happens between the request and the font arriving.
| Value | Behaviour | Effect on LCP |
|---|---|---|
block | Text invisible up to ~3s, then swaps | Bad — if text is your LCP element, LCP waits |
swap | Fallback immediately, swap when ready | Good — paints at once |
optional | Fallback, use font only if near-instant | Best for LCP, may skip the font |
| unset | Behaves like auto, usually ≈ block | Bad by default |
Ours was block. On a text-heavy page the largest contentful element usually is text,
so block means your LCP is gated on a font download. swap costs you a brief
flash of fallback type and buys you a paint.
The fix, in four steps
- 1 · ExtractDecode each base64 blob back to a real
.woff2and write it to disk. Keep woff2 only — every browser that matters has supported it for years, and shipping woff/ttf fallbacks doubles your font payload for browsers you do not have. - 2 · Reference itReplace the data URI with
src: url('/assets/fonts/name.woff2') format('woff2');and setfont-display: swap. - 3 · Preload the one that mattersJust the display face, in
<head>.crossoriginis required even for same-origin fonts — omit it and the browser fetches the font twice. - 4 · Cache immutablyFont filenames are stable and their contents never change:
Cache-Control: public, max-age=31536000, immutable.
<link rel="preload"
href="/assets/fonts/display.woff2"
as="font" type="font/woff2" crossorigin>
Verify the fonts actually loaded rather than trusting the file sizes — decode errors are silent and the page just falls back:
await document.fonts.ready;
[...document.fonts]
.map(f => f.family + ' ' + f.status);
A canvas measurement is stronger still: render a string in the real family and in the fallback, and compare widths. If they match, the webfont is not being used.
What it was worth here
| Before | After | |
|---|---|---|
| Stylesheet, raw | 138.5 KB | 8.3 KB |
| Stylesheet, over the wire | ~141,000 bytes | 2,654 bytes |
| Fonts | Inside the CSS, render-blocking | 98 KB binary, parallel, cached a year |
| font-display | block | swap |
A 98% reduction in render-blocking bytes. The fonts still download — they are simply no longer standing between the visitor and the first paint, and they are cached for a year afterwards instead of being re-fetched with every CSS edit.
Send us a URL and we will tell you what is blocking your paint
Render-blocking assets, font strategy, compression and real Core Web Vitals — measured on your pages, with every finding attributed to the file causing it.
Book a call →