The Performance Cost of Web Fonts
Web fonts are render-blocking resources that can cause both delayed text rendering and layout shifts. When a browser encounters a web font, it may hide text (FOIT — Flash of Invisible Text) or show a fallback font that causes a layout shift when the web font loads (FOUT — Flash of Unstyled Text). Both degrade user experience and impact Core Web Vitals.
Font Loading Behaviors
- FOIT (block): Text is invisible until the font loads. Default in most browsers for 3 seconds.
- FOUT (swap): Fallback font shown immediately, swapped when web font loads. Causes layout shift.
- Optional: Font only used if already cached. No layout shift, but may show fallback permanently.
- Fallback: Short block period (~100ms), then fallback. Swap when font arrives within 3s.
Optimal Font Loading Strategy
// next.config.js — Next.js automatic font optimization
// Next.js automatically inlines Google Font CSS at build time.
// For local fonts, use next/font:
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
import localFont from 'next/font/local';
// Google Fonts — automatically self-hosted, no external requests
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Use font-display: swap
variable: '--font-inter', // CSS variable for Tailwind
preload: true,
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
});
// Local fonts — full control over loading
const customFont = localFont({
src: [
{ path: '../fonts/custom-regular.woff2', weight: '400', style: 'normal' },
{ path: '../fonts/custom-medium.woff2', weight: '500', style: 'normal' },
{ path: '../fonts/custom-bold.woff2', weight: '700', style: 'normal' },
],
display: 'swap',
variable: '--font-custom',
preload: true,
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable} ${customFont.variable}`}>
<body className={inter.className}>{children}</body>
</html>
);
}Font Subsetting
A typical font file contains glyphs for many languages and special characters. If you only need Latin characters, subsetting can reduce font file sizes by 70-90%. Tools like glyphhanger or fonttools analyze which characters your site uses and generate optimized subsets.
// Font subsetting with fonttools (Python) — run as a build step
// pip install fonttools brotli
// subset-fonts.ts — Node.js wrapper for font subsetting
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
interface SubsetConfig {
inputFont: string;
outputFont: string;
unicodeRange: string;
features: string[];
}
function subsetFont(config: SubsetConfig): void {
const { inputFont, outputFont, unicodeRange, features } = config;
const command = [
'pyftsubset',
inputFont,
`--output-file=${outputFont}`,
`--unicodes=${unicodeRange}`,
'--flavor=woff2',
'--layout-features=' + features.join(','),
'--desubroutinize',
'--no-hinting',
].join(' ');
execSync(command);
const originalSize = fs.statSync(inputFont).size;
const subsetSize = fs.statSync(outputFont).size;
const savings = ((1 - subsetSize / originalSize) * 100).toFixed(1);
console.log(`${path.basename(inputFont)}: ${(originalSize/1024).toFixed(1)}KB -> ${(subsetSize/1024).toFixed(1)}KB (${savings}% smaller)`);
}
// Latin subset for Western European languages
subsetFont({
inputFont: 'fonts/Inter-Regular.ttf',
outputFont: 'public/fonts/inter-latin.woff2',
unicodeRange: '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',
features: ['kern', 'liga', 'calt', 'ccmp'],
});Preventing Font Layout Shifts
The biggest CLS impact from fonts comes when the fallback font has different metrics than the web font. The CSS size-adjust, ascent-override, descent-override, and line-gap-override properties let you match fallback font metrics to your web font, eliminating the layout shift.
// Matching fallback font metrics to prevent CLS
// Use the @font-face override descriptors
// In your global CSS:
// @font-face {
// font-family: 'Inter';
// src: url('/fonts/inter-latin.woff2') format('woff2');
// font-weight: 400;
// font-style: normal;
// font-display: swap;
// }
//
// /* Adjusted fallback that matches Inter's metrics */
// @font-face {
// font-family: 'Inter Fallback';
// src: local('Arial');
// ascent-override: 90.49%;
// descent-override: 22.56%;
// line-gap-override: 0%;
// size-adjust: 107.06%;
// }
//
// body {
// font-family: 'Inter', 'Inter Fallback', sans-serif;
// }
// Generating size-adjust values programmatically
// Using fontkit to read font metrics
import fontkit from 'fontkit';
interface FontMetrics {
unitsPerEm: number;
ascent: number;
descent: number;
lineGap: number;
avgCharWidth: number;
}
function getFontMetrics(fontPath: string): FontMetrics {
const font = fontkit.openSync(fontPath);
const os2 = font['OS/2'];
return {
unitsPerEm: font.unitsPerEm,
ascent: os2.sTypoAscender,
descent: Math.abs(os2.sTypoDescender),
lineGap: os2.sTypoLineGap,
avgCharWidth: os2.xAvgCharWidth,
};
}
function calculateOverrides(webFont: FontMetrics, fallbackFont: FontMetrics) {
const sizeAdjust = webFont.avgCharWidth / fallbackFont.avgCharWidth;
return {
sizeAdjust: `${(sizeAdjust * 100).toFixed(2)}%`,
ascentOverride: `${((webFont.ascent / webFont.unitsPerEm / sizeAdjust) * 100).toFixed(2)}%`,
descentOverride: `${((webFont.descent / webFont.unitsPerEm / sizeAdjust) * 100).toFixed(2)}%`,
lineGapOverride: `${((webFont.lineGap / webFont.unitsPerEm / sizeAdjust) * 100).toFixed(2)}%`,
};
}Font Optimization Checklist
- Self-host fonts: Eliminate third-party connection overhead. Next.js does this automatically with next/font.
- Use WOFF2: Best compression for web fonts, near-universal browser support.
- Subset aggressively: Only include the character sets you actually need.
- Preload critical fonts: Add preload hints for fonts used above the fold.
- Use font-display: swap: Show text immediately with a fallback font.
- Match fallback metrics: Use size-adjust and override descriptors to prevent CLS.
- Limit font variants: Only load the weights and styles you actually use.