Back FoxCode

Web fonts are costing 500ms

Two extra HTTP connections, no subsetting, broken fallbacks, and a GDPR violation for good measure. A practical guide to doing fonts right in 2026.

·10 min read

Most sites load fonts the same way: a <link> to Google Fonts in <head>, no preconnect, no subsetting, no fallback metrics. The font loads whenever it loads, text flashes in, CLS spikes.

A single Google Fonts request on a cold mobile connection adds 300-800ms to LCP. Two font families with separate stylesheets can compound to 1.5 seconds. (web.dev: Best practices for fonts, Harry Roberts: Google Fonts performance analysis)

How the browser actually loads fonts

The browser doesn't request fonts when it parses CSS. It requests them when it finds a text node that needs them - after building the full render tree. By the time a font request fires, the browser has already fetched HTML, parsed it, fetched and parsed CSS, built the DOM and CSSOM, combined them into a render tree, and found a text node using that font family.

On a slow 4G connection this chain alone is 1-2 seconds.

Then, if you're loading from Google Fonts, two more round trips: one to fonts.googleapis.com for the stylesheet, another to fonts.gstatic.com for the actual binary. Two separate domains, two separate TLS handshakes.

<!-- What most sites do: two extra connections, zero preloading -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700" rel="stylesheet">

The preconnect hint at least eliminates the handshake latency for those two origins:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

But preconnect is a bandage. The real fix is getting rid of the third-party requests entirely.

font-display: the most misunderstood property

font-display controls what happens during those loading delays. There are three distinct behaviors:

FOIT (Flash of Invisible Text) - the default when font-display is omitted. Browsers hide text for up to 3 seconds while the font loads. Safari historically waited forever. From a user's perspective: blank paragraphs that suddenly appear.

FOUT (Flash of Unstyled Text) - text renders immediately in the fallback system font, then swaps when the web font arrives. font-display: swap. The "flash" causes a visual jump - CLS - unless you've calibrated the fallback.

font-display: optional - the browser gives the font 100ms. If it hasn't arrived, the fallback stays for the entire page load. The font is still downloaded in the background and cached for future visits. Zero CLS. The most aggressive but also the cleanest.

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-latin.woff2') format('woff2');
  font-display: swap; /* or optional, or fallback */
}

Which value to use depends on the context:

ValueInvisible periodSwap windowCLS riskBest for
block3000msinfiniteNoneIcon fonts only
swap0msinfiniteHighBody text with tuned fallback
fallback100ms3000msMediumGeneral purpose
optional100msnoneZeroDecorative, non-critical

If the font is your LCP element - a big hero heading - use swap with proper fallback metrics (covered below). For everything else, optional is underrated and Lighthouse will thank you.

INTERACTIVE DEMO - font-display behavior
font loaded
0ms2400ms
Fallback font

The quick brown fox

Click "Simulate page load" to see the behavior

Block period0ms
Swap windowinfinite
CLS riskhigh
Best forBody text with tuned fallback

Variable fonts

A variable font encodes a continuous design space in a single file. Instead of font-regular.woff2, font-medium.woff2, font-bold.woff2, font-bold-italic.woff2 - one file, any value along the weight axis from 100 to 900.

The five standard axes:

AxisCSS propertyExample
wghtfont-weightfont-weight: 350
wdthfont-stretchfont-stretch: 87.5%
italfont-stylefont-style: italic
slntfont-style: obliquefont-style: oblique -12deg
opszfont-optical-sizingfont-optical-sizing: auto

Custom axes use uppercase 4-character tags. Recursive has CASL (casualness), CRSV (cursive). You can animate them:

@keyframes weight-pulse {
  from { font-weight: 100; }
  to   { font-weight: 900; }
}
/* Variable font axes are animatable in CSS */

The file size argument is where it gets interesting. For Roboto - 12 static styles (Thin through Black including italics) - the total woff2 payload is ~840KB. Roboto Flex (the variable version) is ~370KB. Roughly half, with infinite intermediary weights included.

The pattern holds across most major font families. If you're loading more than two static styles from the same family, a variable font is almost always the right call. Under two styles - 400 and 700 for body and headings - the overhead of the variable font design space may actually make it larger than the two individual files.

Browser support for variable fonts is ~97% globally. This has not been a concern since 2020.

One footgun: font-variation-settings doesn't inherit correctly when you set only some axes. If you write font-variation-settings: 'wght' 700 somewhere and font-variation-settings: 'GRAD' 1 in a child, the child loses wght. Use the high-level CSS properties (font-weight, font-stretch) where possible - they cascade correctly.

Subsetting: the highest-leverage optimization

A full Unicode Inter contains roughly 3,900 glyphs covering Latin, Cyrillic, Greek, Vietnamese, and more. If your site is English, you need about 220 of them.

Subsetting strips the unused glyphs from the font file. The unicode-range descriptor in @font-face tells the browser to only download that file when the page actually contains characters from that range:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
                 U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02AF, U+1E00-1E9F, U+1EF2-1EFF, U+A720-A7FF;
}

The browser downloads inter-latin.woff2 for an English page. inter-latin-ext.woff2 only downloads if it finds characters like ě, ą, ñ. This is exactly what Google Fonts has been doing automatically for years - it's why their fonts feel fast even without any optimization on your end.

The file size impact is significant. Inter Regular at full Unicode is ~310KB. Latin subset: ~54KB. That's an 83% reduction. For Noto Sans (designed to cover all scripts) the difference is even more extreme - 556KB down to 22KB, a 96% reduction. (Numbers measured from Google Fonts downloads via HTTP Archive 2024 Web Almanac - Fonts chapter)

For subsetting your own font files, the standard tool is pyftsubset from the fonttools Python library:

pip install fonttools brotli

pyftsubset inter.ttf \
  --output-file=inter-latin.woff2 \
  --flavor=woff2 \
  --layout-features='*' \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+2000-206F,U+20AC,U+2122"

glyphhanger (by Zach Leatherman) takes a different approach - crawl a URL, analyze the actual characters used, subset to exactly those. Most aggressive, but requires re-running when content changes.

The self-hosting argument

Google Fonts CDN was convenient when it had a genuine performance argument: shared cache. If fonts.gstatic.com/inter.woff2 was already in the browser cache from another site, your page loaded it instantly.

That argument died in October 2020 when Chrome 86 implemented cache partitioning. Each origin gets its own cache namespace. A font cached from site-a.com is not reused for site-b.com. Firefox and Safari followed. There is no more cache sharing. The CDN performance advantage - already small with HTTP/2 - is now the only remaining case, and serving fonts from your own CDN (Cloudflare, Fastly, Bunny) eliminates even that.

When a user's browser contacts fonts.googleapis.com, it sends an IP address to Google's US servers. Under GDPR, IP = personal data. The Landgericht München ruled in January 2022 that embedding Google Fonts without consent violates GDPR Art. 6(1). The Austrian DPA (DSB) issued similar findings. €100 fine in that case, but the legal exposure is real for EU-facing sites.

Self-hosting is strictly better in 2025: no third-party connections, full caching control (Cache-Control: public, max-age=31536000, immutable), preload possible (you know the exact filename), zero GDPR exposure.

If you still want a CDN API for convenience - without the Google tracking - Bunny Fonts is a drop-in compatible replacement. Same API surface, servers in EU, explicit no-IP-logging policy. Just change the domain:

# Google
https://fonts.googleapis.com/css2?family=Inter:wght@400;700

# Bunny (same response format)
https://fonts.bunny.net/css?family=inter:400,700

For typography that doesn't look like every other site using Montserrat and Lato: Fontshare by Indian Type Foundry. Free, no tracking, professional quality - Satoshi, Cabinet Grotesk, Clash Display, General Sans.

Most are offered as variable fonts. @nuxt/fonts supports it natively.

Fallback metrics and zero-CLS swaps

font-display: swap causes CLS because the fallback system font (Arial, Helvetica) has different glyph metrics than the web font. Different x-height, different character width, different line height behavior. A paragraph that's 200px tall in Arial may be 215px in Inter. Everything below shifts.

CSS font metric override descriptors let you adjust how the fallback renders to match the web font:

@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 107.06%;
  ascent-override: 76.99%;
  descent-override: 19.2%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter-fallback', sans-serif;
}

size-adjust scales the entire glyph box to compensate for different UPM (units per em) between the fonts - the single most impactful value. The override descriptors (ascent-override, descent-override, line-gap-override) fine-tune the line box geometry. Done correctly, CLS from the font swap drops to effectively zero.

Calculating these values requires reading the font's OpenType metrics tables. fontpie automates this:

npx fontpie inter.woff2 --name Inter --style normal --weight 400
# Outputs ready-to-paste @font-face with correct overrides

@nuxt/fonts: the shortcut

If you're on Nuxt, @nuxt/fonts handles the entire workflow automatically. Given what we've covered, here's what it does at build time:

  1. Scans CSS and Vue components for font-family references
  2. Downloads font files from the configured provider (Google, Bunny, Fontshare, local)
  3. Stores them locally and rewrites CSS src URLs to your own origin
  4. Runs fontaine internally to calculate size-adjust and metric overrides for each font
  5. Generates fallback @font-face declarations with the correct values
  6. Injects everything - you write nothing manually

The config is minimal:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/fonts'],
  fonts: {
    families: [
      { name: 'Inter', provider: 'google' },
      { name: 'Cabinet Grotesk', provider: 'fontshare' },
    ],
    defaults: {
      weights: [400, 700],
      subsets: ['latin'],
    },
  },
})

Or just use the font in CSS and the module detects it:

body {
  font-family: 'Inter', sans-serif; /* @nuxt/fonts auto-downloads and self-hosts */
}

What you get in the built CSS without writing any @font-face rules:

@font-face {
  font-family: 'Inter';
  src: url('/_fonts/inter-latin.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
  unicode-range: U+0000-00FF, ...;
}

@font-face {
  font-family: 'Inter fallback';
  src: local('Arial');
  size-adjust: 107.06%;
  ascent-override: 76.99%;
  descent-override: 19.2%;
  line-gap-override: 0%;
}

And it rewrites your font-family stack to include the fallback automatically. No Google Fonts request at runtime, no GDPR exposure, near-zero CLS from font swap. The only thing it doesn't handle for you is the font-display value - set that in your CSS or via defaults.display in config.

One caveat: the module downloads fonts at build time, which means a network request during nuxt build. In CI environments without internet access, pre-download and commit the files to the repo.

woff2 only

This warrants its own callout. woff2 uses Brotli compression and is 20-30% smaller than woff for the same font. Browser support is 97% globally - Chrome 36+, Firefox 39+, Safari 10+, Edge 14+.

The only browsers that don't support woff2 are effectively dead (IE11, ancient Safari). Stop shipping woff, ttf, and eot fallbacks. Your src line:

src: url('/fonts/inter-latin.woff2') format('woff2');
/* No fallback formats. None needed. */

Eliminate the CDN chain, subset to Latin, switch to a variable font, add fallback metrics - on a cold mobile connection that's commonly 400-800ms off LCP and CLS that stops being a problem. @nuxt/fonts handles most of this with a five-line config change.

After that, choosing a font is actually a design question again, which is where it should be.

Continue Reading