50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 23, 2026·9 min read

FOUT, FOIT, and the Flash Wars: How to Load Web Fonts Without Embarrassing Yourself

Web fonts are the silent killers of perceived performance. Here's what's actually happening and how to fix it for good.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

FOUT, FOIT, and the Flash Wars: How to Load Web Fonts Without Embarrassing Yourself

You've seen it. You load a page, everything renders cleanly — then suddenly the text snaps from one font to another like someone swapped your brand guidelines mid-sentence. Or worse, the text is completely invisible for a half second while the font loads. Your users didn't sign up to watch a magic trick. That's FOUT and FOIT, and they're entirely preventable.

Web fonts are one of those things that look simple on the surface — just a link tag or a CSS import — but underneath they're a minefield of render-blocking behavior, layout shifts, and browser inconsistencies. We've debugged this on enough projects (including some late-night production surprises) to have developed strong opinions about how to handle it.

What Actually Happens When a Font Loads

When a browser hits your page, it starts building the render tree. At some point it encounters text that needs a custom font. That font isn't in the browser cache. Now the browser has a decision to make: do I show the text with a fallback font while the real one loads, or do I hide the text entirely until the custom font arrives?

The answer depends on the browser and how you've configured things. This gives us our two villains:

  • FOUT (Flash of Unstyled Text): The browser shows text in a fallback font immediately, then swaps to the custom font when it arrives. You see a flash as the layout re-renders with the new font metrics.
  • FOIT (Flash of Invisible Text): The browser hides the text entirely while waiting for the font. If the font takes too long (3 seconds in most browsers), it falls back anyway. For those 3 seconds, your users are reading nothing.
  • FOFT (Flash of Faux Text): A newer variation where the browser renders bold/italic using synthetic styles while waiting for those specific weights to load. It's less jarring but still visible.

FOIT is generally worse from a user experience standpoint — invisible content is more disorienting than slightly wrong content. But FOUT causes Cumulative Layout Shift (CLS), which hurts your Core Web Vitals score. Neither is acceptable if you can avoid it.

The font-display Property: Your First Line of Defense

The CSS font-display descriptor controls how a font is swapped in. Most developers have heard of it, fewer understand the actual differences between values.

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap; /* The most common recommendation */
}

/* The five options:
   auto     → browser decides (usually FOIT)
   block    → short invisible period, then swap (FOIT with timeout)
   swap     → show fallback immediately, swap when ready (FOUT)
   fallback → very short invisible period, then fallback if slow
   optional → very short invisible period, may not swap at all
*/

For most sites, font-display: swap is the right call. It eliminates FOIT completely and trades it for FOUT — which at least shows your users content. The downside is the layout shift when the font arrives. font-display: optional is underrated for non-critical decorative fonts: it gives the browser a tiny window to load the font, and if it doesn't make it in time, the fallback sticks for that page load. No flash, no shift. The font just quietly appears on the next cached load.

Size-Adjust: The CLS Killer You're Probably Not Using

Here's the real fix for layout shift that most tutorials skip. The reason FOUT causes CLS isn't the swap itself — it's that your custom font and your fallback font have different metrics. Different x-height, different ascenders, different average character width. When the swap happens, text reflows.

The size-adjust descriptor lets you scale a fallback font to approximately match your custom font's metrics. Combined with ascent-override, descent-override, and line-gap-override, you can get your fallback looking so close to the real font that the swap is nearly invisible.

/* First, define your actual font */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
}

/* Then define an adjusted fallback that matches Inter's metrics */
@font-face {
  font-family: 'InterFallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

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

Getting those exact numbers used to require manual trial-and-error or measuring font metrics by hand. These days, tools like fontaine (by Daniel Roe, from the Nuxt team) can calculate them automatically. And if you're using next/font, this is handled for you behind the scenes — which brings us to the Next.js-specific stuff.

next/font: Why You Should Just Use It

Next.js ships a font optimization system that handles most of this automatically. It downloads fonts at build time, self-hosts them, inlines the critical CSS, and generates adjusted fallback metrics. You stop depending on Google Fonts CDN availability and get zero layout shift almost for free.

// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google';
import localFont from 'next/font/local';

// Google Font — downloaded at build time, self-hosted
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  // Only load the weights you actually use
  weight: ['400', '500', '600', '700'],
});

// Serif for headings — only latin subset
const playfair = Playfair_Display({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-playfair',
  weight: ['400', '700'],
});

// Local font with multiple weights via font files
const customFont = localFont({
  src: [
    { path: '../fonts/custom-regular.woff2', weight: '400', style: 'normal' },
    { path: '../fonts/custom-bold.woff2', weight: '700', style: 'normal' },
  ],
  display: 'swap',
  variable: '--font-custom',
});

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

The variable approach (using CSS custom properties) is more flexible than applying className directly to body — it lets you use each font in Tailwind classes or CSS without fighting specificity. Define font-family: var(--font-inter) wherever you need it.

If you're still doing <link rel='stylesheet' href='https://fonts.googleapis.com/...'> in 2025, stop. You're adding a third-party dependency, a DNS lookup, a TLS handshake, and potential GDPR issues to every page load. Self-host with next/font.

Preloading: Telling the Browser What You Actually Need

Even with self-hosting, the browser still discovers fonts late — typically after parsing CSS. By that point, it's already in the middle of building the render tree and you've wasted time. Preloading critical fonts gives the browser an early hint to start fetching before it even sees the CSS.

// next/font handles preloading automatically, but if you're doing
// it manually in the <head>:

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        {/* Preload only the most critical font variant */}
        <link
          rel="preload"
          href="/fonts/inter-regular.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
        {/* Don't preload everything — you'll congest the connection */}
      </head>
      <body>{children}</body>
    </html>
  );
}

Resist the temptation to preload every font variant. Preloading competes with other critical resources like your above-the-fold images and JavaScript. Preload the one or two fonts that appear in your largest contentful paint element — usually your body font regular weight — and let the rest load normally.

Subsetting: Load Only the Characters You Need

A full Inter font file contains glyphs for hundreds of languages. If your site is in English, you're downloading characters you'll never render. Subsetting strips out everything except the characters you use.

// next/font subsets are the easiest way to handle this
const inter = Inter({
  subsets: ['latin'], // Just Latin characters
  // Available: 'latin', 'latin-ext', 'cyrillic', 'cyrillic-ext',
  // 'greek', 'greek-ext', 'vietnamese'
});

// If you need Eastern European characters too:
const interEU = Inter({
  subsets: ['latin', 'latin-ext'],
});

// For local fonts, use a tool like glyphhanger or pyftsubset
// to generate subset woff2 files at build time:
// pyftsubset inter.ttf --output-file=inter-latin.woff2 \
//   --flavor=woff2 --unicodes=U+0000-00FF,U+0131,U+0152-0153

For Latin-only sites, subsetting can cut font file sizes by 70-80%. That's not a rounding error — it meaningfully affects load time on slower connections. We've seen inter-latin.woff2 come in around 25KB versus the full Inter file at over 300KB. That difference matters especially on mobile.

Auditing What You Actually Ship

The most common font performance mistake isn't misconfiguration — it's loading fonts you don't need. Designers add a font to the system, developers wire it up, the design changes, and now you're loading a full type family with 8 weights for one decorative heading that got cut in sprint 4.

  • Open Chrome DevTools → Network tab → filter by 'Font'. Count how many font files you're loading and their sizes.
  • Check Coverage (DevTools → More tools → Coverage) to see how much of your CSS is actually used.
  • Run Lighthouse and look for 'Ensure text remains visible during webfont load' and 'Reduce the impact of third-party code'.
  • Use WebPageTest's filmstrip view to visually see exactly when fonts cause repaints.
  • Check your @font-face declarations in CSS — are you loading weights 100-900 when you only use 400 and 700?

Our rule: if a font weight or style doesn't appear in your Figma file, don't load it. Variable fonts are an option if you legitimately use many weights — one file covers the whole range — but they're larger than two or three specific weight files, so only reach for them when you're actually using that range.

The Tailwind CSS Font Variable Pattern

If you're using Tailwind, the CSS variable approach with next/font plays nicely with the config. Here's the setup we use in our peal.dev templates:

// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
        heading: ['var(--font-playfair)', 'Georgia', 'serif'],
        mono: ['var(--font-geist-mono)', 'Consolas', 'monospace'],
      },
    },
  },
};

export default config;

// Now you can use font-sans, font-heading, font-mono in your components
// <h1 className="font-heading text-4xl">Hello</h1>
// The CSS variable is set by next/font on the html element

This keeps fonts consistently applied across components without everyone remembering to apply specific className values. Change the font in one place (the layout file), and it propagates everywhere via the CSS variable and Tailwind config.

If you want to skip the configuration overhead entirely, our templates on peal.dev come with this pattern already wired up — next/font with CSS variables, Tailwind integration, and sensible fallback stacks included out of the box.

What Good Actually Looks Like

Here's a quick checklist. If you can check all of these, you're in good shape:

  • Self-hosting fonts via next/font (no Google Fonts CDN link in your HTML)
  • font-display: swap on all @font-face declarations
  • Adjusted fallback metrics so the swap doesn't cause layout shift (next/font does this automatically)
  • Only the subsets your content actually uses are loaded
  • Only the weights and styles present in your design are loaded
  • Critical font preloaded if it's in your LCP element
  • Zero font-related CLS in your Lighthouse report
  • No FOIT visible in the WebPageTest filmstrip
The goal isn't zero font loading time — it's making the font loading invisible to users. Instant-feeling text with a fallback that's close enough to the real thing beats invisible text or a jarring swap every time.

Web fonts are one of those areas where the right setup takes maybe an hour, and then it's done. You don't need to revisit it every sprint. Get next/font configured, pick font-display: swap, subset aggressively, and stop loading weights you don't use. Your Lighthouse score improves, your CLS drops, and your users never notice the font loading — which is exactly how it should be.

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