We spent two hours optimizing our Lighthouse score on a client project — got everything green, felt great about ourselves — then watched a user recording where the entire page text vanished for 800ms before snapping back in a completely different font. The score said 98. The experience was terrible. That's the font loading problem in a nutshell.
Web fonts are one of those things that feel solved until they aren't. You drop a Google Fonts link in your head tag, move on, and then six months later someone notices the layout shifts or the invisible text flicker and files a bug. Let's break down what's actually happening and how to fix it properly.
FOUT vs FOIT: Two Ways Fonts Can Ruin Your Day
FOUT stands for Flash of Unstyled Text. The browser renders text immediately using a fallback system font, then swaps to your custom font once it loads. You get a visible jump — layout shifts, text reflowing, users losing their reading position. It's jarring, but at least people can read the content.
FOIT stands for Flash of Invisible Text. The browser hides text entirely while waiting for the custom font. Users stare at a blank page that looks broken. On a slow connection, this can last several seconds. This is actually worse than FOUT in most cases because the page appears non-functional.
Both happen because of the font-display CSS property — or rather, the lack of a sensible value for it. The browser's default behavior differs by engine: Chrome and Firefox show invisible text for up to 3 seconds (FOIT) then fall back. Safari has slightly different timing. None of them do what you probably want.
The root cause of both problems is the same: the browser is waiting on a network request before it can render text, and it handles that wait badly by default.
The font-display Property: Your Main Tool
The font-display property in your @font-face declaration controls this behavior. There are five values and only two of them are worth using in most situations.
- auto — browser default, behavior varies. Avoid.
- block — invisible text for up to 3s, then swap. The FOIT culprit.
- swap — show fallback immediately, swap when font loads. Causes FOUT but text is always visible.
- fallback — invisible for ~100ms, then fallback font, then swap only if font loads quickly. Good middle ground.
- optional — invisible for ~100ms, then only use custom font if it's already cached. Never causes FOUT on repeat visits.
For most sites, you want either swap or fallback. Use swap when your brand font is critical and you'd rather have the layout jump than invisible text. Use fallback when the layout shift from swapping is so bad it ruins the experience. Use optional for decorative fonts that aren't worth the performance hit.
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap; /* Show fallback immediately, swap when ready */
}
/* Or if layout shift is a real problem: */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: fallback; /* 100ms block, then fallback, swap only if fast */
}How Next.js font/google Solves Most of This
If you're on Next.js 13+, you should be using next/font instead of importing from Google Fonts directly. Here's why: the old way meant a network request to Google's servers on every page load. Next.js font optimization downloads the font files at build time, self-hosts them, and generates the CSS automatically with sensible defaults.
// app/layout.tsx
import { Inter, Playfair_Display } from 'next/font/google'
// Variable font — one file covers all weights
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter', // Expose as CSS variable
})
// Non-variable font — specify the weights you actually use
const playfair = Playfair_Display({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
variable: '--font-playfair',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${playfair.variable}`}>
<body className={inter.className}>
{children}
</body>
</html>
)
}The variable property is useful when you want to use the font in Tailwind or in arbitrary CSS. The className approach applies the font directly to the element. Use variable when you need to reference the font in multiple places — say, a heading font that you apply via Tailwind classes throughout your components.
// tailwind.config.ts
import type { Config } from 'tailwindcss'
const config: Config = {
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
display: ['var(--font-playfair)', 'Georgia', 'serif'],
},
},
},
}
export default config
// Now in your components:
// <h1 className="font-display text-4xl">Big heading</h1>
// <p className="font-sans">Body text</p>The Layout Shift Problem: Matching Your Fallback Font
Even with font-display: swap, you still get layout shift (CLS) when the font loads and the text reflows. This happens because your custom font and your fallback font have different metrics — different letter spacing, line height, x-height. One takes up more space than the other, so when the swap happens, everything moves.
The fix is to adjust your fallback font metrics to match your custom font as closely as possible. This is fiddly but worth it. Next.js has a built-in adjustFontFallback option that does this automatically for Google Fonts.
// Next.js handles fallback metric adjustment automatically
const inter = Inter({
subsets: ['latin'],
display: 'swap',
adjustFontFallback: true, // Default is true — generates adjusted fallback
})
// What it generates under the hood (you don't write this):
// @font-face {
// font-family: '__Inter_Fallback_xyz';
// src: local('Arial');
// ascent-override: 90.49%;
// descent-override: 22.56%;
// line-gap-override: 0%;
// size-adjust: 107.06%;
// }
// The numbers are calculated to match Inter's metrics exactlyIf you're using a local font (not from Google), you'll need to calculate these values yourself or use a tool. Monica Dinculescu's font-style-matcher at meowni.ca/font-style-matcher is the best visual tool for this. You can also use the fontaine package which automates the metric calculation for any font.
Self-Hosting: When and How
next/font/google already self-hosts for you at build time, so if you're using Google Fonts through Next.js, you're done. But if you have a custom font — a paid typeface, a brand font, something off the beaten path — you need to self-host it yourself.
The format you want is woff2. It's supported everywhere that matters and has the best compression. You don't need woff, ttf, or eot anymore unless you're supporting IE11, which you're not.
// app/layout.tsx — using a local custom font
import localFont from 'next/font/local'
const brandFont = localFont({
src: [
{
path: '../public/fonts/brand-font-regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/brand-font-bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
variable: '--font-brand',
// Next.js will try to match fallback metrics automatically
// For custom fonts, you can provide them manually:
// fallback: ['Arial'],
// adjustFontFallback: false, // Set to false to provide your own
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={brandFont.variable}>
<body>{children}</body>
</html>
)
}One thing we got burned by: putting fonts in the /public folder is fine, but make sure you're using variable fonts where possible. A variable font is a single file that handles the entire weight range (100-900) instead of separate files per weight. Inter, Geist, and most modern fonts offer variable versions. Fewer files means fewer requests means faster loading.
Preloading: Getting Ahead of the Problem
Even with self-hosted fonts and font-display: swap, there's still a delay before the font loads. Preloading tells the browser to fetch the font file as early as possible, before it even starts parsing the CSS. Next.js does this automatically when you use next/font, but it's worth understanding what it does.
<!-- What Next.js generates in your <head> -->
<link
rel="preload"
href="/_next/static/media/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- If you're doing this manually (older setup): -->
<link
rel="preload"
href="/fonts/your-font.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<!-- Note: crossorigin is required even for same-origin fonts -->
<!-- Omitting it will cause the font to be downloaded twice -->Don't preload every font variant. Preload only the fonts that appear above the fold — typically your body font and maybe your primary heading font. Preloading unused fonts wastes bandwidth and delays other critical resources. Next.js is smart about this when you use next/font with the preload option (it defaults to true).
The crossorigin attribute on font preloads is mandatory even for same-origin fonts. It's one of the most common font optimization mistakes. Without it, the browser fetches the font twice — once for the preload, once when the CSS requests it.
Subsetting: Stop Loading Characters You'll Never Use
A full Unicode font can be hundreds of kilobytes. If your site is in English, you're loading Cyrillic, Greek, Vietnamese, and hundreds of symbols you'll never render. Subsetting strips out the character ranges you don't need.
When you use next/font/google with subsets: ['latin'], that's exactly what's happening. The latin subset is roughly 200-300 characters — everything you need for Western European languages. The full Inter font is around 800KB. The latin subset is under 100KB. That's a significant difference on a slow connection.
// Be specific about which subsets you need
const inter = Inter({
subsets: ['latin'], // Western European
// subsets: ['latin', 'latin-ext'], // + extended Latin (accents, etc.)
// subsets: ['latin', 'cyrillic'], // + Russian etc.
display: 'swap',
})
// For self-hosted fonts, you need to subset manually
// Use glyphhanger or fonttools to generate subsets:
// glyphhanger --subset=./font.woff2 --formats=woff2 --US_ASCII
// Or use a service like Transfonter to generate subsets onlineChecking If It Actually Worked
After all this optimization, how do you verify it's working? A few concrete ways:
- Chrome DevTools > Network tab > filter by 'Font' — check that fonts are served from your own domain, not fonts.gstatic.com
- Check the Timing tab on the font request — you want to see 'Preload' in the initiator, not a late CSS-triggered request
- Lighthouse > Opportunities — look for 'Ensure text remains visible during webfont load' (should be passing)
- PageSpeed Insights > CLS score — font swaps are a major CLS contributor, this should be near 0
- Chrome DevTools > Performance > record a page load and look for layout shifts in the Experience row
- Throttle to Slow 3G and watch the page load — this exposes any remaining FOIT or FOUT issues
The real test is the Slow 3G simulation. On fast connections everything looks fine. It's the slow connections where font loading problems become obvious and where the impact on user experience is highest. If you can get through a Slow 3G load without wincing, you're in good shape.
We include all of this in our Next.js templates at peal.dev — fonts are self-hosted with next/font, properly subsetted, with adjusted fallback metrics so your CLS score stays clean from the start. It's the kind of thing that's easy to get right if you start with a solid foundation and a pain to retrofit later.
The Short Version
If you're starting a new Next.js project right now, the setup is: use next/font/google or next/font/local, set display: 'swap', use variable fonts where available, specify only the subsets you need, and leave adjustFontFallback on. That's 90% of the work and Next.js handles most of it automatically.
If you're fixing an existing site with the old Google Fonts link tag approach, the migration is worth doing. Remove the <link> tags from your document, switch to next/font, and you'll immediately eliminate the cross-origin request and get the preloading and subsetting for free. Your LCP will thank you, your CLS will thank you, and you'll never have to debug a mysterious font-doubling issue caused by a missing crossorigin attribute at 2am again.
Fonts should be invisible infrastructure. If users notice your fonts loading, something is wrong. The goal is text that's always readable, with the nice font showing up as fast as possible — not a perfect score on a synthetic benchmark while real users watch text disappear.
