50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
best-practicesMay 24, 2026·8 min read

Web Fonts Are Probably Hurting Your LCP — Here's How to Fix That

FOUT, FOIT, and font swapping explained without the hand-waving, plus real fixes that actually move your Core Web Vitals.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Web Fonts Are Probably Hurting Your LCP — Here's How to Fix That

You've built something beautiful. Custom typeface, perfect kerning, your designer is happy. Then you run a Lighthouse audit and your LCP is 4.2 seconds and there's a jarring flash every time someone loads the page. Web fonts, man. They give with one hand and take with the other.

The good news: font performance is one of those areas where doing the right thing is actually well-documented. The bad news: most tutorials stop at 'add font-display: swap' and call it a day. That's not enough. Let's go deeper.

The Two Villains: FOUT and FOIT

FOUT — Flash of Unstyled Text — is when the browser renders text in a fallback font first, then swaps to your web font once it loads. You see a layout shift. Things jump around. Users notice, especially on slower connections.

FOIT — Flash of Invisible Text — is arguably worse. The browser hides text entirely while the font loads. Your page looks broken for a moment. On a slow 3G connection that 'moment' can be three seconds of blank content. Google hates it. Users hate it more.

Both happen because fonts are render-blocking by default. The browser needs the font file before it can paint text, and font files aren't small. A single weight of a variable font can be 200KB+. Multiply that by the four weights your designer 'absolutely needs' and you're looking at 800KB of blocking resources.

The browser's default behavior for font loading is optimistic: it waits up to 3 seconds for the font to load before showing invisible text, then falls back to the system font. This is FOIT by default in most browsers.

font-display: The Control Knob You Actually Have

The font-display descriptor in your @font-face rules is the main lever you have. Here's what each value actually does — not the MDN description, but what you'll see in practice:

  • auto — browser decides. Usually means FOIT. Don't use this.
  • block — invisible text for up to 3 seconds, then swap. This is the FOIT behavior. Avoid unless you have a very specific reason.
  • swap — show fallback font immediately, swap when custom font loads. Causes FOUT. Best for body text where readability matters more than aesthetics.
  • fallback — invisible for ~100ms, then fallback font, then a brief window to swap. After ~3 seconds, no more swapping. Good middle ground.
  • optional — invisible for ~100ms, then if font isn't cached, browser uses fallback and never swaps. Best performance, worst first-load appearance.

For most projects: use swap for body text (readability wins), fallback for headings (you get one swap attempt without the jarring mid-read shift), and optional for decorative fonts that aren't critical.

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap; /* Show fallback immediately, swap when ready */
  src: url('/fonts/inter-regular.woff2') format('woff2');
}

@font-face {
  font-family: 'CalSans';
  font-style: normal;
  font-weight: 600;
  font-display: fallback; /* Brief invisible period, then one swap attempt */
  src: url('/fonts/calsans-semibold.woff2') format('woff2');
}

Preloading: Tell the Browser What's Important

font-display alone doesn't make your fonts load faster — it just controls what happens while they're loading. To actually speed things up, you need preloading. This tells the browser 'hey, you're going to need this font, start downloading it now instead of waiting until you parse the CSS.'

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

Two things people always get wrong here: the crossorigin attribute is required even if the font is on your own domain — browsers treat font requests as CORS requests regardless. And don't preload every font weight you have. Preloading too many resources defeats the purpose. Pick the 1-2 fonts that appear above the fold.

We made the mistake of preloading five font weights on a project once. The browser was spending so much bandwidth on font preloads that images were loading slower. Preloading is about prioritization, not 'load everything faster.'

next/font: The Right Way to Handle This in Next.js

If you're on Next.js (and if you're reading this blog, you probably are), the built-in next/font module handles most of this automatically. It downloads fonts at build time, self-hosts them from your domain, inlines the font-face declarations to eliminate extra network requests, and automatically adds size-adjust to reduce layout shift.

// app/fonts.ts
import { Inter, Cal_Sans } from 'next/font/google';
import localFont from 'next/font/local';

export const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  // Only load weights you actually use
  weight: ['400', '500', '600'],
});

// For a local font
export const customFont = localFont({
  src: [
    {
      path: '../public/fonts/custom-regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: '../public/fonts/custom-bold.woff2',
      weight: '700',
      style: 'normal',
    },
  ],
  variable: '--font-custom',
  display: 'fallback', // More conservative for headings
});
// app/layout.tsx
import { inter, customFont } from './fonts';

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

The key thing next/font does that people miss: it generates an optimal fallback font with CSS adjustments (size-adjust, ascent-override, descent-override, line-gap-override) that makes the fallback font match your custom font's metrics as closely as possible. This dramatically reduces layout shift even before your font loads.

Reducing Layout Shift: The size-adjust Trick

Layout shift happens when your fallback font (usually Arial or Times New Roman) has different metrics than your web font. When the swap happens, elements reflow. Your Cumulative Layout Shift (CLS) score tanks.

The fix is to adjust your fallback font to match your web font's metrics. You can do this manually with CSS, or use a tool like the Fallback Font Generator (fontpie) to calculate the right values.

/* Manually tuned fallback for Inter */
@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  ascent-override: 90.20%;
  descent-override: 22.48%;
  line-gap-override: 0.00%;
  size-adjust: 107.40%;
}

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

These values look arbitrary because they kind of are — you're tuning them until the fallback and the web font occupy the same space on screen. next/font calculates these for you automatically, which is another reason to use it over manual @font-face declarations.

Subsetting: The Performance Gain People Leave on the Table

A full variable font file includes glyphs for hundreds of languages. If your app is English-only, you're loading characters for Cyrillic, Greek, Vietnamese, and more that will never render. Subsetting strips those out.

When using next/font, set the subsets option. When loading from Google Fonts manually, add &subset=latin to your URL. When using self-hosted fonts, run them through a tool like fonttools (pyftsubset) or glyphhanger before deploying.

// next/font with subsetting
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'], // Only latin glyphs — drops ~60% of file size for many fonts
  // For Eastern European languages: ['latin', 'latin-ext']
  // For Greek: ['latin', 'greek']
  display: 'swap',
});

We saw a font go from 187KB to 31KB just by subsetting to latin only. That's not a typo. The difference in load time on a mobile connection is significant.

The Checklist: What We Actually Audit

When we're doing a font performance pass on a project, here's what we look at:

  • Are fonts self-hosted? Third-party font CDNs add DNS lookup time and block rendering. Self-host everything.
  • Is font-display set correctly? Check in DevTools > Network > Font. If you see 'block' or missing font-display, fix it.
  • Are you loading only the weights you use? Audit your CSS for font-weight values and only load those.
  • Are fonts preloaded? Critical above-the-fold fonts should have <link rel='preload'>.
  • Is subsetting applied? Check file sizes — anything over 100KB for a latin-only font is suspicious.
  • Are fallback metrics adjusted? Run your page through WebPageTest and watch the filmstrip for layout shifts on font swap.
  • Using variable fonts where possible? One variable font file instead of 4 separate weights is usually a win.
Variable fonts are a real win when you need multiple weights of the same font. One file, all weights. The file is bigger than a single weight but smaller than three separate weight files. Use them when you need 3+ weights of the same family.

One thing we check in all the peal.dev templates before shipping is that next/font is set up correctly with proper subsetting, and that the font loading strategy matches the content type — swap for body copy, fallback for display headings. It's one of those things that's easy to ignore until you see a CLS score of 0.3 in production and spend a Saturday debugging it.

Testing Your Work

Lighthouse tells you about font issues, but it's not the best tool for actually seeing FOUT/FOIT in action. Here's what works better:

  • WebPageTest filmstrip view — shows you frame-by-frame what the page looks like during load. You can actually see the flash.
  • Chrome DevTools Network throttling — set to Slow 3G and reload. Watch your font request timing in the waterfall.
  • Chrome DevTools Rendering tab — check 'Flash paint' to see when repaints happen. Font swaps cause repaints.
  • PageSpeed Insights — the 'Ensure text remains visible during webfont load' audit will flag FOIT directly.

The real test is throttled mobile. What feels imperceptible on a fast desktop connection becomes a half-second flash on a phone on 4G. Test on real conditions before you call it done.

Font performance isn't glamorous work. Nobody's going to tweet about your font-display: fallback decision. But it shows up in your Core Web Vitals, it shows up in user experience, and it's the kind of craft that separates a polished product from something that feels slightly off without knowing why. Get it right once, don't think about it again.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders