View contents
Introduction
Web fonts allow designers to move beyond system fonts, creating unique brand experiences. However, fonts are render-blocking resources—the browser must download and parse font files before displaying text that uses them. Poorly optimized font loading can cause invisible text (FOIT), layout shifts, and degraded Core Web Vitals scores.
Understanding how browsers handle font loading is essential for balancing visual design with performance.
How Browsers Load Web Fonts
When a browser encounters text that uses a custom font, it follows a specific process:
Font Loading Timeline:
┌─────────────────────────────────────────────────────────────┐
│ 1. Browser parses CSS, discovers @font-face declaration │
│ └── Font download begins (if font-display allows) │
│ │
│ 2. Browser needs to render text using that font │
│ └── Default: Wait for font (FOIT - Flash of Invisible) │
│ │
│ 3. Font downloads complete │
│ └── Text renders with custom font │
│ │
│ 4. Font download fails or times out (~3 seconds) │
│ └── Fallback font displayed │
└─────────────────────────────────────────────────────────────┘
FOIT vs FOUT
Two terms describe the visual experience during font loading:
| Term | Meaning | User Experience |
|---|---|---|
| FOIT | Flash of Invisible Text | Text is hidden until font loads (default browser behavior) |
| FOUT | Flash of Unstyled Text | Fallback font shown first, then swapped when custom font loads |
Which is better? Generally, FOUT is preferred for performance because users can read content immediately, even if the styling changes slightly when the custom font loads.
The font-display Property
CSS provides font-display to control how browsers handle font loading:
@font-face {
font-family: 'Custom Font';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* Controls loading behavior */
}
font-display Values
| Value | Behavior | Best For |
|---|---|---|
| auto | Browser decides (usually FOIT) | Not recommended |
| block | Short FOIT (~3s), then fallback | Icon fonts only |
| swap | Immediate fallback, swap when ready | Body text, headings |
| fallback | Very short FOIT (~100ms), limited swap window | Balanced approach |
| optional | Very short FOIT, may skip font entirely | Non-critical fonts |
Recommendation: Use font-display: swap for most text to ensure content is always readable.
Impact on Core Web Vitals
Font loading affects multiple performance metrics:
| Metric | How Fonts Affect It |
|---|---|
| LCP | If LCP element uses custom font, invisible text delays LCP measurement |
| CLS | Font swap can cause layout shifts if fallback and custom fonts have different sizes |
| FCP | Blocked text delays First Contentful Paint |
The LCP Connection
If your largest content element is text using a custom font:
Without font-display: swap
├── Font request starts: 0ms
├── Text invisible (FOIT): 0-2500ms
├── Font loads, text appears: 2500ms
└── LCP recorded: 2500ms ❌ Poor
With font-display: swap
├── Font request starts: 0ms
├── Fallback text visible: 50ms
├── FCP recorded: 50ms ✅
├── Font loads, text swaps: 2500ms
└── LCP recorded: 50ms ✅ Good (text was visible earlier)
Modern Font Formats
Not all font formats are equal. Modern formats offer better compression:
| Format | Compression | Browser Support | Recommendation |
|---|---|---|---|
| WOFF2 | Best (~30% smaller than WOFF) | 97%+ | Primary format |
| WOFF | Good | 99%+ | Fallback only |
| TTF/OTF | None | Universal | Avoid for web |
| EOT | Moderate | IE only | Legacy only |
Best Practice: Serve WOFF2 as primary format with WOFF fallback:
@font-face {
font-family: 'Custom Font';
src: url('/fonts/custom.woff2') format('woff2'),
url('/fonts/custom.woff') format('woff');
font-display: swap;
}
Common Font Loading Problems
Problem 1: Too Many Font Files
Issue: Loading multiple weights and styles (regular, bold, italic, bold-italic) multiplies download size.
Solution: Limit to 2-3 font files. Use font-synthesis for missing weights if acceptable.
Problem 2: No font-display
Issue: Default browser behavior causes invisible text.
Solution: Always specify font-display: swap (or fallback for non-critical fonts).
Problem 3: Late Discovery
Issue: Browser discovers font need only when parsing CSS.
Solution: Preload critical fonts in <head>:
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
Problem 4: Third-Party Font Services
Issue: Google Fonts or other services add extra DNS lookups and connections.
Solution: Self-host fonts for best performance, or use preconnect:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
Measuring Font Performance
Using Chrome DevTools
- Open DevTools → Network tab
- Filter by “Font”
- Check file sizes and load times
- Look for fonts blocking render
Using Lighthouse
Run a Lighthouse audit and check for:
- “Ensure text remains visible during webfont load”
- “Preload key requests” (for critical fonts)
- “Avoid enormous network payloads” (font file sizes)
Implementation
Strategy 1: Optimize @font-face Declarations
Complete @font-face Syntax
@font-face {
font-family: 'Brand Font';
src: url('/fonts/brand-regular.woff2') format('woff2'),
url('/fonts/brand-regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6;
}
@font-face {
font-family: 'Brand Font';
src: url('/fonts/brand-bold.woff2') format('woff2'),
url('/fonts/brand-bold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6;
}
Key Descriptors Explained
@font-face Descriptors:
┌─────────────────────────────────────────────────────────────┐
│ font-family │ Name to reference in CSS │
│ src │ Font file URLs with format hints │
│ font-weight │ Numeric weight (100-900) or keywords │
│ font-style │ normal, italic, oblique │
│ font-display │ Loading behavior (swap recommended) │
│ unicode-range │ Character subset to download │
│ font-stretch │ Width variant (condensed, expanded) │
│ size-adjust │ Adjust metrics to match fallback │
│ ascent-override │ Fine-tune line height matching │
│ descent-override│ Fine-tune baseline matching │
└─────────────────────────────────────────────────────────────┘
Strategy 2: Implement font-display Correctly
Choosing the Right Value
/* For body text - prioritize readability */
@font-face {
font-family: 'Body Font';
src: url('/fonts/body.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
}
/* For headings - balanced approach */
@font-face {
font-family: 'Heading Font';
src: url('/fonts/heading.woff2') format('woff2');
font-display: fallback; /* Brief FOIT, then fallback */
}
/* For decorative/non-critical text */
@font-face {
font-family: 'Decorative Font';
src: url('/fonts/decorative.woff2') format('woff2');
font-display: optional; /* May not load on slow connections */
}
/* For icon fonts - brief invisibility acceptable */
@font-face {
font-family: 'Icons';
src: url('/fonts/icons.woff2') format('woff2');
font-display: block; /* Icons shouldn't show fallback */
}
font-display Timeline Visualization
font-display: swap
├── 0ms: Request font
├── 0ms: Show fallback immediately
├── Font loads: Swap to custom font
└── Infinite swap window
font-display: fallback
├── 0ms: Request font
├── ~100ms: FOIT period (invisible)
├── ~100ms: Show fallback if font not ready
├── ~3s: Swap window ends
└── After 3s: Keep fallback even if font loads
font-display: optional
├── 0ms: Request font
├── ~100ms: FOIT period (invisible)
├── ~100ms: Show fallback OR custom font
└── No late swap - what's shown stays
Strategy 3: Preload Critical Fonts
Basic Preloading
<head>
<!-- Preload critical fonts BEFORE CSS -->
<link
rel="preload"
href="/fonts/body-regular.woff2"
as="font"
type="font/woff2"
crossorigin
>
<link
rel="preload"
href="/fonts/heading-bold.woff2"
as="font"
type="font/woff2"
crossorigin
>
<!-- CSS that uses these fonts -->
<link rel="stylesheet" href="/styles/main.css">
</head>
Important Preload Rules
Preload Best Practices:
┌─────────────────────────────────────────────────────────────┐
│ ✅ DO │
│ ├── Preload fonts used above the fold │
│ ├── Include `crossorigin` even for same-origin fonts │
│ ├── Specify `type` for format hints │
│ ├── Limit to 2-3 critical fonts maximum │
│ └── Place preload before stylesheet links │
│ │
│ ❌ DON'T │
│ ├── Preload all fonts (wastes bandwidth) │
│ ├── Forget crossorigin (causes double download) │
│ ├── Preload fonts not used on current page │
│ └── Preload WOFF if browser supports WOFF2 │
└─────────────────────────────────────────────────────────────┘
Conditional Preloading with JavaScript
// Only preload if font not cached
if (!sessionStorage.getItem('fontsLoaded')) {
const preloadLink = document.createElement('link');
preloadLink.rel = 'preload';
preloadLink.as = 'font';
preloadLink.type = 'font/woff2';
preloadLink.crossOrigin = 'anonymous';
preloadLink.href = '/fonts/body.woff2';
document.head.appendChild(preloadLink);
}
Strategy 4: Self-Host Fonts
Why Self-Hosting is Better
| Aspect | Google Fonts | Self-Hosted |
|---|---|---|
| DNS Lookup | Extra lookup required | None (same origin) |
| Connection | New connection to fonts.gstatic.com | Reuses existing |
| Caching | Shared cache removed in Chrome | Your cache headers |
| Control | Limited to their options | Full control |
| Privacy | Sends user data to Google | No third-party data |
Converting Google Fonts to Self-Hosted
# Step 1: Download fonts using google-webfonts-helper
# Visit: https://gwfh.mranftl.com/fonts
# Step 2: Choose formats (WOFF2 + WOFF recommended)
# Step 3: Download and extract to /fonts/ directory
# Step 4: Copy generated CSS or create your own:
/* Self-hosted Inter font */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-regular.woff2') format('woff2'),
url('/fonts/inter-regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-600.woff2') format('woff2'),
url('/fonts/inter-600.woff') format('woff');
font-weight: 600;
font-style: normal;
font-display: swap;
}
Strategy 5: Subset Fonts
Why Subsetting Matters
Full Font vs Subset:
┌─────────────────────────────────────────────────────────────┐
│ Full Roboto Regular: │
│ ├── All Latin characters │
│ ├── All Cyrillic characters │
│ ├── All Greek characters │
│ ├── All Vietnamese characters │
│ └── Total: ~150 KB │
│ │
│ Latin-Only Subset: │
│ ├── Latin characters only │
│ └── Total: ~20 KB (87% smaller!) │
└─────────────────────────────────────────────────────────────┘
Using unicode-range
/* Only download Latin characters */
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto-latin.woff2') format('woff2');
font-display: swap;
unicode-range: 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;
}
/* Separate file for Cyrillic (only downloaded if needed) */
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto-cyrillic.woff2') format('woff2');
font-display: swap;
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
Creating Subsets with Tools
# Using pyftsubset (fonttools)
pip install fonttools brotli
# Create Latin subset
pyftsubset "Roboto-Regular.ttf" \
--unicodes="U+0000-00FF,U+0131,U+0152-0153" \
--layout-features="*" \
--flavor="woff2" \
--output-file="roboto-latin.woff2"
# Using glyphhanger (npm)
npx glyphhanger https://yoursite.com --subset="*.ttf" --formats=woff2
Strategy 6: Minimize Layout Shift from Fonts
Using size-adjust and Metric Overrides
/* Match fallback metrics to custom font */
@font-face {
font-family: 'Custom Font';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
/* Adjust these values to match your fallback font */
size-adjust: 105%;
ascent-override: 95%;
descent-override: 22%;
line-gap-override: 0%;
}
/* Define matching fallback stack */
body {
font-family: 'Custom Font', -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
}
Using @font-face for Fallback Adjustment
/* Create adjusted fallback */
@font-face {
font-family: 'Adjusted Arial';
src: local('Arial');
size-adjust: 105.5%;
ascent-override: 100%;
descent-override: 20%;
}
/* Use adjusted fallback in stack */
body {
font-family: 'Custom Font', 'Adjusted Arial', sans-serif;
}
Finding Correct Override Values
Use tools like:
Strategy 7: Variable Fonts
Benefits of Variable Fonts
Traditional Fonts vs Variable:
┌─────────────────────────────────────────────────────────────┐
│ Traditional (4 weights): │
│ ├── Regular: 25 KB │
│ ├── Medium: 26 KB │
│ ├── Semibold: 26 KB │
│ ├── Bold: 27 KB │
│ └── Total: 104 KB (4 files, 4 requests) │
│ │
│ Variable Font: │
│ └── All weights: 70 KB (1 file, 1 request) │
│ │
│ Savings: 34 KB + 3 requests │
└─────────────────────────────────────────────────────────────┘
Implementing Variable Fonts
@font-face {
font-family: 'Inter Variable';
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
font-weight: 100 900; /* Weight range */
font-stretch: 75% 125%; /* Width range (if supported) */
font-style: oblique 0deg 10deg; /* Slant range (if supported) */
font-display: swap;
}
/* Use any weight within range */
h1 { font-weight: 750; }
h2 { font-weight: 650; }
p { font-weight: 400; }
strong { font-weight: 600; }
Strategy 8: Critical Font Loading Pattern
FOFT (Flash of Faux Text)
/* Stage 1: Roman only (fastest) */
@font-face {
font-family: 'LatoInitial';
src: url('/fonts/lato-regular.woff2') format('woff2');
font-display: swap;
unicode-range: U+0041-005A, U+0061-007A; /* A-Z, a-z */
}
/* Stage 2: Full font (loads after) */
@font-face {
font-family: 'Lato';
src: url('/fonts/lato-full.woff2') format('woff2');
font-display: swap;
}
// Progressive enhancement
document.fonts.ready.then(() => {
document.documentElement.classList.add('fonts-loaded');
});
/* Use initial subset first */
body {
font-family: 'LatoInitial', sans-serif;
}
/* Upgrade when full font ready */
.fonts-loaded body {
font-family: 'Lato', sans-serif;
}
Measuring Font Performance
Key Metrics to Track
Before Optimization:
├── Font files total: 450 KB
├── Font requests: 8
├── Time to first text: 2.8s (FOIT)
├── CLS from font swap: 0.12
└── Lighthouse Performance: 65
After Optimization:
├── Font files total: 85 KB (↓81%)
├── Font requests: 2 (↓75%)
├── Time to first text: 0.3s (↓89%)
├── CLS from font swap: 0.01 (↓92%)
└── Lighthouse Performance: 94
Lighthouse Audits to Check
- “Ensure text remains visible during webfont load”
- “Preload key requests”
- “Avoid enormous network payloads”
- “Minimize main-thread work” (font parsing)
Optimization Checklist
Before deploying, verify:
- [ ] Using WOFF2 format with WOFF fallback
- [ ]
font-display: swapon all @font-face rules - [ ] Critical fonts preloaded in
<head> - [ ] Fonts self-hosted (not third-party CDN)
- [ ] Subsets created for language needs
- [ ] Limited to 2-4 font files total
- [ ] Fallback font metrics matched
- [ ] Variable font used if multiple weights needed
- [ ] Font files compressed and cached properly
Related Articles
Learn more about optimizing font loading:
- LCP Optimization Guide - Fonts often affect LCP
- CLS Explained - Prevent font-swap layout shifts
- Render-Blocking Resources Explained - How fonts block rendering
📚 Back to Performance SEO Hub - Explore all performance topics
References
- MDN Web Docs - font-display
- web.dev - Best Practices for Fonts
- Chrome Developers - Ensure Text Remains Visible
- MDN Web Docs - @font-face
- CSS-Tricks - A Comprehensive Guide to Font Loading Strategies
Try It Yourself
Want to check your site’s font loading?
🔧 Download UXR SEO Analyzer (Free, 100% local analysis)
Disclaimer: The analyzers in this extension are reference guides based on official documentation from MDN, web.dev, and Chrome Developers. They do not represent absolute truths about how search engines evaluate your content—only search engines know their internal algorithms. Use these recommendations as a starting point to improve your site.
Last updated: December 15, 2025