How Font Loading Affects Performance
Web fonts affect three Core Web Vitals:
- LCP (Largest Contentful Paint) — If the largest element on the page is text styled with a web font, LCP is blocked until that font loads and the text renders.
- CLS (Cumulative Layout Shift) — When a fallback font is swapped for a web font, different metrics (character width, line height, ascenders/descenders) cause text to reflow, shifting surrounding content.
- FCP (First Contentful Paint) — Render-blocking font requests delay the first paint entirely.
FOIT vs FOUT
These are the two visible symptoms of slow font loading:
FOIT (Flash of Invisible Text) — Text is invisible while the font loads. The browser reserves space for text but shows nothing until the font arrives. This is the default behavior in Chrome/Edge/Firefox (they hide text for up to 3 seconds, then show fallback).
FOUT (Flash of Unstyled Text) — Fallback font shows immediately, then swaps to the web font when it loads. This is visible but doesn't block content — users can read the page sooner.
FOUT is almost always better than FOIT for user experience. font-display: swap opts into FOUT behavior.
Diagnosing Font Issues
Check Font Loading Waterfall
DevTools → Network → filter by "Font" type. Look for:
- When fonts start loading — If fonts don't start until late in the waterfall, they're being discovered late (need preload).
- How many font files load — Each weight/style is a separate file. Loading 6+ font files is a red flag.
- Where fonts load from — Third-party origins (fonts.gstatic.com, use.typekit.net) add DNS + connection overhead.
- File size — WOFF2 fonts should typically be 15–50 KB each. Over 100 KB suggests the font isn't subsetted.
Check for Layout Shift from Font Swap
DevTools → Performance → record a page load → look for layout shift markers. Click on a layout shift to see which elements moved. If text elements shift when fonts load, you have a font-swap CLS problem.
// Programmatically detect font-related layout shifts
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.value > 0.01) {
entry.sources?.forEach(source => {
if (source.node?.tagName) {
console.log(`CLS from ${source.node.tagName}: ${entry.value.toFixed(3)}`);
}
});
}
}
}).observe({ type: 'layout-shift', buffered: true });
Audit Font Usage
// List all fonts loaded by the page
document.fonts.forEach(font => {
console.log(`${font.family} ${font.weight} ${font.style}: ${font.status}`);
});
// Check which fonts are actually used in rendering
// (fonts that loaded but aren't used are wasted bandwidth)
const usedFonts = new Set();
document.querySelectorAll('*').forEach(el => {
usedFonts.add(getComputedStyle(el).fontFamily.split(',')[0].trim().replace(/"/g, ''));
});
console.log('Fonts used in DOM:', [...usedFonts]);
font-display Strategies
The font-display descriptor in @font-face controls what happens while the font is loading:
| Value | Block Period | Swap Period | Best For |
|---|---|---|---|
auto |
~3s (browser default) | Infinite | Never use — unpredictable |
block |
3s | Infinite | Icon fonts only (invisible squares look broken) |
swap |
~100ms | Infinite | Body text, headings (shows fallback immediately) |
fallback |
~100ms | ~3s | Good compromise — shows fallback, but stops swapping after 3s |
optional |
~100ms | None | Performance-critical pages — uses font only if already cached |
Recommended Strategy
/* Body/heading fonts: swap (show content immediately) */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
/* Icon font: block (invisible icons look broken) */
@font-face {
font-family: 'Icons';
src: url('/fonts/icons.woff2') format('woff2');
font-display: block;
}
/* Optional: for performance-critical hero text on repeat visits */
@font-face {
font-family: 'HeroFont';
src: url('/fonts/hero.woff2') format('woff2');
font-display: optional;
/* First visit: shows fallback, caches font
Return visits: font loads from cache instantly */
}
Google Fonts: Add display=swap
<!-- Without display parameter, Google Fonts defaults to font-display: auto (FOIT) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700" rel="stylesheet">
<!-- Add &display=swap to get font-display: swap in the generated CSS -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
Reducing CLS from Font Swap
When font-display: swap shows a fallback font and then swaps in the web font, the different character metrics cause text to reflow. Fix this by matching the fallback font's metrics to the web font:
CSS size-adjust and Override Descriptors
/* Define a fallback that matches the web font's metrics */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%; /* Scale to match Inter's character width */
ascent-override: 90%; /* Match vertical metrics */
descent-override: 22%;
line-gap-override: 0%;
}
/* Use the adjusted fallback in the font stack */
body {
font-family: 'Inter', 'Inter Fallback', sans-serif;
}
How to find the right values: Use the Font Style Matcher or Automatic Font Matching tools. Enter your web font and system fallback — they calculate the override values.
Next.js handles this automatically:
// next/font calculates size-adjust values at build time
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
// Next.js generates a fallback @font-face with
// size-adjust, ascent-override, descent-override
// automatically matched to Inter
});
Preloading Critical Fonts
Fonts referenced in CSS files are discovered late — the browser must download and parse the CSS before it knows about the font files. Preloading eliminates this delay:
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
Rules for font preloading:
crossoriginis required even for same-origin fonts (fonts are always fetched with CORS).type="font/woff2"prevents browsers that don't support WOFF2 from downloading the file.- Only preload fonts used above the fold. Preloading fonts used only in the footer wastes bandwidth.
- Limit to 1–2 font files. Preloading too many saturates bandwidth that could load other critical resources.
Self-Hosting vs Google Fonts CDN
Self-Hosting (Recommended)
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
Advantages:
- No third-party DNS lookup or connection overhead.
- Preload works efficiently (same origin).
- No privacy concerns (no requests to Google servers).
- Full control over caching headers.
- No dependency on Google's CDN availability.
How to self-host Google Fonts:
- Download from google-webfonts-helper — gives you optimized WOFF2 files and CSS.
- Or use the
@fontsourcenpm packages:npm install @fontsource/inter.
Google Fonts CDN
Advantages:
- Easy setup (one
<link>tag). - Automatic subsetting by browser (Google serves different files based on Unicode ranges).
Disadvantages:
- Two extra connections (fonts.googleapis.com + fonts.gstatic.com).
- Cache partitioning in modern browsers means cached Google Fonts from other sites don't benefit your site (the old "shared cache" advantage no longer exists since Chrome 86+).
- Privacy implications (requests to Google servers with user IP).
If you use Google Fonts CDN, always add preconnect:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
Reducing Font File Size
Subsetting
Most fonts include thousands of glyphs for multiple languages. If your site is English-only, subset to Latin characters:
# Using pyftsubset (from fonttools)
pip install fonttools brotli
# Subset to Latin characters only
pyftsubset Inter-Regular.ttf \
--output-file=inter-regular-latin.woff2 \
--flavor=woff2 \
--layout-features='kern,liga,calt,dlig' \
--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"
# Result: typically 50-70% smaller than the full font
Variable Fonts
Variable fonts store multiple weights/styles in a single file. Instead of loading Regular + Bold + Italic (3 files, ~60 KB each = 180 KB), a variable font might be 80–120 KB for all weights:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900; /* Full weight range from one file */
font-display: swap;
}
/* Use any weight, not just predefined steps */
h1 { font-weight: 650; }
body { font-weight: 380; }
When variable fonts are worth it: When you use 3+ weights of the same family. For 1–2 weights, individual static files are usually smaller.
WOFF2 Only
WOFF2 has 95%+ browser support. Unless you need to support IE11, you can drop WOFF and TTF formats entirely:
/* Don't do this: */
@font-face {
src: url('font.woff2') format('woff2'),
url('font.woff') format('woff'),
url('font.ttf') format('truetype');
}
/* Do this: */
@font-face {
src: url('font.woff2') format('woff2');
}
Font Loading API (Advanced)
For precise control over when and how fonts load:
// Load fonts programmatically
async function loadCriticalFonts() {
const font = new FontFace('Inter', 'url(/fonts/inter-var.woff2)', {
weight: '100 900',
display: 'swap',
});
try {
await font.load();
document.fonts.add(font);
document.documentElement.classList.add('fonts-loaded');
} catch (err) {
console.warn('Font failed to load:', err);
// Fallback fonts are already visible thanks to font-display: swap
}
}
// Wait until fonts are ready before showing font-dependent UI
document.fonts.ready.then(() => {
console.log('All fonts loaded');
});
Related Guides
- Prefetch and Preconnect — preload and preconnect techniques for fonts
- LCP Optimization — font loading directly affects Largest Contentful Paint