EmpireUI
Get Pro
← Blog8 min read#google fonts#performance#font-display

Google Fonts Performance: font-display, preload and next/font

Google Fonts tanks your CLS and LCP if you load them wrong. Here's exactly how font-display, preload, and next/font fix that — with real code.

Designer working on typography layout on a computer screen

Why Google Fonts Hurts Your Core Web Vitals

The default Google Fonts embed — that <link> tag from fonts.google.com — ships two round-trips before a single glyph renders. First the browser fetches the CSS stylesheet from fonts.googleapis.com, then it parses it and fetches the actual .woff2 files from fonts.gstatic.com. Both are cross-origin. Both block your text from painting. That's your LCP score bleeding out in real time.

Honestly, the damage is worse than most devs realize until they run a Lighthouse audit. A mid-range Android device on 4G will commonly show a 400–600ms font-induced render delay on a page that *feels* fast on your MacBook. The browser renders your text in a fallback system font first, then when the custom font arrives it swaps — causing a layout shift (CLS) that Google literally penalizes in rankings.

Worth noting: this isn't Google Fonts being careless. The multi-step fetch is a consequence of how CDN-served font stylesheets work. The CSS file they serve is dynamically tailored per user-agent — Chrome gets WOFF2, older Safari gets TTF — so they can't inline the font URLs directly into your HTML. That said, you can work around every single one of these issues with three tools: font-display, preload, and next/font.

Before we fix things, a quick sanity check on scope. If you're already self-hosting your fonts or using a design system like Empire UI that ships with pre-optimized CSS, some of this is already handled for you. But if any fonts.googleapis.com URLs appear in your network waterfall, keep reading.

font-display: The Single Line That Changes Everything

The font-display descriptor lives inside an @font-face rule and tells the browser what to do during the font-fetch window. There are five values — auto, block, swap, fallback, and optional — and picking the wrong one is where most teams quietly destroy their CLS score.

font-display: swap is what everybody reaches for first, and it's a reasonable default: text shows in your fallback font immediately, then swaps to the custom font when it arrives. No invisible text. But that swap *is* a layout shift if your fallback metrics differ from your web font metrics. A heading set in Inter that's 36px tall might jump 4–6px when it swaps to Neue Haas Grotesk. That 4px shift is measurable CLS.

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap; /* visible text immediately, swap on load */
  font-weight: 100 900;
  font-style: normal;
}

font-display: optional is the hidden gem. It gives the browser a ~100ms window to load the font. If it arrives in time, great. If not, the browser uses the fallback for the entire page load and *won't swap at all* — eliminating the layout shift entirely. In practice, on a cached repeat visit the font loads in time. On a first cold visit on slow connections, users get a system font with zero jank. That's often the right trade-off for body text.

font-display: fallback sits in the middle: a 100ms block period, then a 3-second swap window, then the font is abandoned for that page load. Use it when your brand font is important but you'd rather not shift the layout on every slow connection.

Preloading Font Files the Right Way

Preloading is a separate mechanism from font-display. Where font-display controls the *swap behavior*, preload controls the *discovery timing* — it tells the browser to fetch the font file early, before it even parses the CSS that references it. Combined, they're much more powerful than either alone.

<!-- In your <head>, before any stylesheets -->
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

The crossorigin attribute is non-optional here — even when the font is self-hosted on the same origin. Fonts are always fetched with CORS semantics. Omit crossorigin and the browser fetches the font twice: once for the preload hint and once when the @font-face rule resolves. You'd double the download. Ask me how I know.

One more thing — only preload the fonts you *actually* need on first paint. If Inter is used for body copy that's below the fold, preloading it is a bandwidth waste. Reserve preload for the font rendering your hero headline or above-the-fold text. Preloading 4 font files on a page that paints two of them above-fold will push those two files further back in the queue.

Quick aside: if you're still loading Google Fonts from their CDN (not self-hosting), you can add <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> to warm the connection. It's not as good as a direct preload but it shaves 100–200ms off the cross-origin TCP handshake. This pairs well with the gradient generator workflow where you're iterating on visuals quickly and want the font to load fast in dev.

next/font: The One-Line Fix for Next.js Apps

If you're on Next.js 13 or later, stop hand-rolling font optimization. next/font (shipped in Next.js 13.0.0, October 2022) handles everything: it downloads Google Fonts at build time, serves them from your own domain, injects the @font-face rule with correct font-display, and adds the preload link tag automatically. You go from four network requests to zero cross-origin requests.

// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap', // or 'optional' for zero CLS
  variable: '--font-inter',
  preload: true,
})

const firaCode = Fira_Code({
  subsets: ['latin'],
  weight: ['400', '500'],
  display: 'optional',
  variable: '--font-fira-code',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
      <body>{children}</body>
    </html>
  )
}

The variable option is the part most tutorials skip. Instead of applying inter.className directly to the <body>, you set a CSS custom property (--font-inter) on <html>, then reference it anywhere in your CSS or Tailwind config. That means your design tokens stay clean and you don't end up with hardcoded font-family strings scattered across components.

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
        mono: ['var(--font-fira-code)', 'monospace'],
      },
    },
  },
}

For local fonts — say you've licensed a typeface and want to self-host it — next/font/local works the same way. Point it at your file, set display and preload, and Next handles the rest. No third-party origin, no CORS, no extra DNS lookup. This is the approach Empire UI's templates use so that preview pages load fast even on cold visits.

Fixing Layout Shift with Size-Adjust and Font Metrics

Even with font-display: swap and a preload, you can get CLS if your fallback font has different metrics than your web font. The fix landed in Chrome 92 (2021): size-adjust, ascent-override, descent-override, and line-gap-override. These descriptors let you warp a system font's geometry to match your web font — so when the swap happens, the text takes up the same space and nothing moves.

@font-face {
  font-family: 'Inter-Fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

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

Finding the right numbers isn't guesswork — Malte Ubl published a tool called fontaine (and Next.js integrates it automatically via next/font) that computes these values from the font metrics. If you're not using Next, the @capsizecss/metrics package and fontaine can generate the override values for any font pair. Run it once, copy the output, done.

Look, this feels like a lot of ceremony for fonts. But a CLS score above 0.1 in 2026 actively suppresses your search ranking. A 500ms LCP delay on mobile is the difference between users bouncing and staying. The return on a two-hour font audit is measurable in both UX and organic traffic. It's one of the few performance wins that directly touches the glassmorphism components and visual-heavy pages where typography is front and center.

Subsetting: Cut Font File Size by 60–80%

A full-weight Inter variable font is around 800KB. The subset you actually need for an English-language UI — Latin characters, numerals, punctuation — is 45–80KB as WOFF2. That's a 90% reduction. Subsetting is non-negotiable if you're self-hosting.

The glyphhanger CLI or pyftsubset (from the fonttools Python package) handle this. next/font/google does it automatically via the subsets option — subsets: ['latin'] strips every glyph outside the Latin Unicode range. If you're building multilingual apps, you'd add 'latin-ext', 'cyrillic', etc., and they load conditionally.

# Self-hosting workflow with pyftsubset
pyftsubset Inter.ttf \
  --unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD' \
  --flavor=woff2 \
  --output-file=inter-latin.woff2

One more thing — use variable fonts where possible. A single inter-var.woff2 covering weights 100–900 (roughly 70KB subsetted) beats four individual weight files (4 × 40KB = 160KB). Variable fonts also let you use intermediate weights like font-weight: 450 for subtle hierarchy, which pairs nicely when you're dialing in the typography for a box shadow generator UI or any tool interface where readability at small sizes matters.

The full optimization stack — next/font, Latin subset, variable font, font-display: optional, and a size-adjusted fallback — routinely gets font-related CLS to 0.00 and eliminates web-font-related LCP delay entirely. That's the baseline. Anything less is leaving points on the table.

FAQ

Does font-display: swap cause layout shift?

Yes, if your fallback font has different metrics than your web font. Use size-adjust and the metric override descriptors — or font-display: optional — to eliminate the shift entirely.

Does next/font work with self-hosted fonts?

Yes. Use next/font/local and point it at your font file with src. It applies the same build-time optimization, preload injection, and font-display handling as the Google Fonts variant.

Should I preload fonts if I'm using next/font?

No need — next/font sets preload: true by default and injects the <link rel="preload"> tag automatically. Adding a manual preload tag on top would just duplicate the request.

What's the fastest font-display value for Core Web Vitals?

optional gives you zero layout shift because the browser won't swap once it commits to the fallback. Pair it with a size-adjusted fallback @font-face and you effectively get your custom font with no CLS penalty.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS backdrop-filter: blur, brightness, saturate and When to Use EachImage Optimisation in 2026: WebP, AVIF, Lazy Load and LCPWeb Font Loading in 2026: next/font, variable fonts and CLSCore Web Vitals in 2026: LCP, CLS, INP — Measure and Fix