Fixing Cumulative Layout Shift (CLS) from Font Loading: Complete Guide
🔍 Understanding Font-Related CLS
Cumulative Layout Shift (CLS) measures the sum of all unexpected layout shifts that occur during page load. Font-related CLS happens when:
- Fallback font and web font have different metrics (size, spacing, line height)
- Text reflows when the web font replaces the fallback font
- Elements shift position as text containers resize to accommodate different font dimensions
The Font Loading Process
HTML Parsing → CSSOM Construction →
[Font Request Sent] →
[FOIT: Empty/invisible text] OR [FOUT: Fallback font visible] →
[Font Download Complete] →
[Font Swap: Web font replaces fallback] →
[LAYOUT SHIFT: If fonts differ in size] →
[Stable Text: Web font rendering]
📊 Measuring Font-Related CLS
Using web-vitals Library
import { getCLS } from 'web-vitals';
getCLS((cls) => {
console.log(`CLS: ${cls}`);
// Font-related shifts will appear here
});
Chrome DevTools
- Performance tab → Record page load
- Look for "Layout Shift" entries in the timeline
- Hover over shifts to see which elements moved
- Check if shifts correlate with font loading events
Manual Detection
// Detect potential font-related shifts
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
const shift = entry;
console.log('Layout Shift:', shift);
// Check if shift involves text elements
const targets = shift.sources.map(s => s.node);
const hasTextTarget = targets.some(node =>
node.nodeType === Node.TEXT_NODE ||
node.childNodes.some(child => child.nodeType === Node.TEXT_NODE)
);
if (hasTextTarget) {
console.log('⚠️ Potential font-related layout shift');
}
}
}
}).observe({ entryTypes: ['layout-shift'] });
🚨 Root Causes of Font-Related CLS
1. Font Metric Mismatches
Different fonts have different:
- Font-size to height ratio (same px value renders different heights)
- Letter spacing (tracking)
- Word spacing
- Line height
- Baseline position
- Ascender/descender height
Example: Arial vs Helvetica
/* Same font-size, different visual size */
.fallback { font-family: Arial, sans-serif; }
.web-font { font-family: 'Helvetica Neue', sans-serif; }
2. Font Loading Strategies That Cause Shifts
| Strategy | CLS Risk | Description |
|---|---|---|
font-display: block |
High | FOIT period can be long, then big shift when font loads |
font-display: swap |
Medium | Immediate FOUT, then shift when web font loads |
font-display: fallback |
Low-Medium | Short FOIT, then fallback, then potential shift |
font-display: optional |
Low | Uses web font only if available immediately, no shift |
| No font-display specified | High | Browser default (usually block) |
3. Missing Font Display Descriptors
/* ❌ PROBLEMATIC: No font-display means browser chooses (often block) */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
/* Missing font-display! */
}
/* ✅ SOLUTION: Always specify font-display */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
font-display: swap; /* or optional, fallback, etc. */
}
4. Variable Fonts Without Proper Fallbacks
/* ❌ PROBLEMATIC: Variable font fallback metrics differ significantly */
@font-face {
font-family: 'VariableFont';
src: url('/fonts/variable.woff2') format('woff2');
font-variation-settings: 'wght' 400;
}
/* Fallback to system font with very different metrics */
🛠️ Solutions to Eliminate Font-Related CLS
Solution 1: Use font-display: optional (Best for Performance)
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: optional; /* Key: Use if available immediately */
}
/* Results in: */
/* - Fastest possible render (no waiting for font) */
/* - Zero CLS from font loading (uses fallback or nothing) */
/* - Web font used only if cached/available on first paint */
Solution 2: Match Fallback and Web Font Metrics
Use the size-adjust, ascent-override, descent-override, and line-gap-override descriptors:
/* Step 1: Measure your web font */
@font-face {
font-family: 'WebFont';
src: url('/fonts/webfont.woff2') format('woff2');
font-display: swap;
}
/* Step 2: Create matched fallback */
@font-face {
font-family: 'FallbackFont';
src: local('Arial');
/* These values need to be calculated based on your web font */
ascent-override: 90.50%; /* % of web font's ascent */
descent-override: 22.50%; /* % of web font's descent */
line-gap-override: 0.00%; /* % of web font's line gap */
size-adjust: 107.20%; /* Scale fallback to match web font height */
}
/* Step 3: Use the matched fallback */
.body-font {
font-family: 'WebFont', 'FallbackFont', sans-serif;
}
Solution 3: Use system-ui Font Family (Modern Approach)
/* Use system fonts that are already available */
.body-font {
font-family: system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif;
}
/* Benefits: */
/* - No font loading = zero font-related CLS */
/* - Native appearance on each platform */
/* - Instant rendering */
/* - No FOIT/FOUT */
Solution 4: Preload Critical Fonts with Proper Priorities
<!-- In <head> -->
<link rel="preload"
as="font"
href="/fonts/inter-regular.woff2"
type="font/woff2"
crossorigin>
<link rel="preload"
as="font"
href="/fonts/inter-bold.woff2"
type="font/woff2"
crossorigin>
/* Then use with appropriate font-display */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-regular.woff2') format('woff2');
font-weight: 400;
font-display: swap; /* Works well with preload */
}
/* For above-the-fold text, consider: */
@font-face {
font-family: 'Inter-Title';
src: url('/fonts/inter-bold.woff2') format('woff2');
font-weight: 700;
font-display: optional; /* Must be immediately available */
}
Solution 5: Use the Font Loading API for Advanced Control
// Better control over font loading and swapping
document.fonts.ready.then(() => {
// All fonts have loaded, safe to measure/layout
console.log('All fonts ready');
});
// Or check specific fonts
document.fonts.load('400px Inter').then(() => {
// Inter Regular 400px is loaded
// Can safely trigger layout-dependent operations
});
// Fallback for older browsers
if ('fonts' in document) {
// Use Font Loading API
} else {
// Fallback to load event or timeout
}
Solution 6: Inline Critical Font Data (For Tiny Fonts)
/* For very small font files (<2KB), consider inlining */
@font-face {
font-family: 'IconFont';
src: url("data:font/woff2;base64,d09GRgABAAAAAAV4AA0AAAAAAbQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAA...")
format('woff2');
font-weight: normal;
font-style: normal;
font-display: auto; /* or block since it's already loaded */
}
/* Only practical for icon fonts or very small glyph sets */
Solution 7: Use CSS Containment to Isolate Shifts
/* Contain layout shifts to specific elements */
.font-contained {
contain: layout paint; /* or strict/content */
/* Prevents font changes in this element from affecting others */
}
/* Useful for: */
/* - Independent widgets/components */
/* - Ads or embedded content */
/* - Where you can tolerate internal shifts but not page-wide shifts */
🧩 Implementation Strategies by Use Case
Strategy A: Content-Heavy Sites (Blogs, News, Documentation)
Goal: Maximize readability, minimize disruption
/* Approach: Optional font + matched fallback */
@font-face {
font-family: 'Merriweather';
src: url('/fonts/merriweather-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: optional; /* Don't delay render */
}
@font-face {
font-family: 'FallbackSerif';
src: local('Georgia');
ascent-override: 92.00%;
descent-override: 24.00%;
line-gap-override: 8.00%;
size-adjust: 106.50%;
}
body {
font-family: 'Merriweather', 'FallbackSerif', serif;
line-height: 1.6;
}
Strategy B: Application UIs (Dashboards, SaaS, Tools)
Goal: Consistency and brand fidelity
/* Approach: Preload + swap + container strategy */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Preload in HTML head */
<link rel="preload" as="font" href="/fonts/inter-regular.woff2" type="font/woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/inter-bold.woff2" type="font/woff2" crossorigin>
/* Use containment for dynamic content */
.dashboard-panel {
contain: layout paint;
font-family: 'Inter', system-ui, sans-serif;
}
/* Critical above-the-text gets optional */
.header-title {
font-family: 'Inter';
font-weight: 600;
font-display: optional; /* Must be immediate for above-fold */
}
Strategy C: Marketing/Landing Pages
Goal: Pixel-perfect brand experience
/* Approach: Critical fonts optional, rest optional or swap */
@font-face {
font-family: 'BrandHeading';
src: url('/fonts/brand-heading-bold.woff2') format('woff2');
font-weight: 700;
font-display: optional; /* Must be immediately available */
}
@font-face {
font-family: 'BrandBody';
src: url('/fonts/body-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: optional; /* Still prefer optional if possible */
}
/* Fallback to system fonts with adjustments */
@font-face {
font-family: 'SystemHeading';
src: local('Arial Black'), local('Helvetica Bold');
ascent-override: 85.00%; /* Adjust to match BrandHeading */
descent-override: 15.00%;
size-adjust: 120.00%;
}
@font-face {
font-family: 'SystemBody';
src: local('Arial'), local('Helvetica');
ascent-override: 90.00%;
descent-override: 20.00%;
line-gap-override: 5.00%;
size-adjust: 105.00%;
}
h1, h2, h3 {
font-family: 'BrandHeading', 'SystemHeading', sans-serif;
}
body {
font-family: 'BrandBody', 'SystemBody', sans-serif;
}
Strategy D: Performance-Critical Sites (Where every ms counts)
Goal: Fastest possible render
/* Approach: System fonts only or aggressive optional */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif;
/* Zero font loading = zero font-related CLS */
}
/* If brand font is absolutely required: */
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand.woff2') format('woff2');
font-display: optional; /* Only use if instant */
}
/* BrandFont will only be used if already cached */
🧪 Testing and Validation
1. Lab Testing with Web Vitals
// In your test suite or dev tools
import { getCLS } from 'web-vitals';
function measureCLSDuringFontLoad() {
return new Promise((resolve) => {
let clsValue = 0;
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
clsValue += entry.value;
}
}
});
observer.observe({ entryTypes: ['layout-shift'] });
// Measure for 5 seconds after load
setTimeout(() => {
observer.disconnect();
resolve(clsValue);
}, 5000);
});
}
// Usage
measureCLSDuringFontLoad().then(cls => {
console.log(`Font-related CLS: ${cls}`);
if (cls > 0.1) {
console.warn('⚠️ CLS exceeds good threshold');
}
});
2. Manual Testing Checklist
- [ ] Disable cache and test on slow 3G
- [ ] Test with empty cache (first visit)
- [ ] Test with warm cache (repeat visit)
- [ ] Test on multiple devices (mobile, tablet, desktop)
- [ ] Test with different network conditions
- [ ] Check both FOIT and FOUT scenarios
- [ ] Verify no shifts when fonts fail to load
- [ ] Test with JavaScript disabled (if using JS font loading)
3. Automated Visual Regression
// Using Playwright or Cypress for visual testing
import { test, expect } from '@playwright/test';
test('should not have font-related layout shifts', async ({ page }) => {
await page.goto('/test-page');
// Enable layout shift tracking
await page.exposeFunction('reportCLS', (cls) => {
window.__CLS_VALUE = cls;
});
await page.addInitScript(() => {
let clsValue = 0;
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
clsValue += entry.value;
}
}
});
observer.observe({ entryTypes: ['layout-shift'] });
window.reportCLS = () => clsValue;
});
// Wait for fonts to load and stabilize
await page.waitForTimeout(3000); // Adjust based on your font loading
const cls = await page.evaluate(() => window.__CLS_VALUE);
expect(cls).toBeLessThan(0.1); // Good CLS threshold
});
📱 Mobile-Specific Considerations
1. Variable Font Axes on Mobile
/* Some variable font axes don't render consistently on mobile */
@font-face {
font-family: 'MobileVariableFont';
src: url('/fonts/mobile-variable.woff2') format('woff2');
font-variation-settings: 'wght' 400;
}
/* Test thoroughly on iOS/Android */
2. Font Size Adjustments for Touch Targets
/* Ensure font changes don't affect touch target sizes */
.button {
font-family: 'Inter', system-ui, sans-serif;
min-height: 44px; /* Apple/Hugely recommended */
min-width: 44px;
padding: 12px 16px;
/* Use rem/em units that scale with font, but maintain minimums */
}
3. Consider System Fonts for Mobile First
/* Mobile-first approach: start with system fonts */
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif;
}
/* Then enhance for desktop if bandwidth allows */
@media (min-width: 1024px) {
body {
font-family: 'PremiumFont', system-ui, sans-serif;
}
}
🚀 Advanced Optimization Techniques
1. Font Loading Critical Chain Analysis
graph TD
A[HTML Parse Start] --> B[CSSOM Construction]
B --> C[Font Request Dispatch]
C --> D[Network Request]
D --> E[Font Download]
E --> F[Font Parsing]
F --> G[Font Swap]
G --> H[Text Layout]
H --> I[CLS Measurement]
style C fill:#f9f,stroke:#333
style E fill:#ff9,stroke:#333
style G fill:#f96,stroke:#333
style H fill:#9f9,stroke:#333
style I fill:#f66,stroke:#333,color:white
2. Segment Font Loading by Importance
<!-- Critical fonts for above-the-fold -->
<link rel="preload" as="font" href="/fonts/inter-bold.woff2" type="font/woff2" crossorigin>
<!-- Important fonts for below-the-fold viewport -->
<link rel="preload" as="font" href="/fonts/inter-regular.woff2" type="font/woff2" crossorigin
media="(min-width: 600px)">
<!-- Non-critical fonts load with standard priority -->
<link rel="preload" as="font" href="/fonts/inter-light.woff2" type="font/woff2" crossorigin>
3. Use CSS Font Loading Strategy Based on Connection
/* Base: System fonts */
body {
font-family: system-ui, sans-serif;
}
/* Enhance for fast connections */
@media (prefers-reduced-data: no-preference) {
body {
font-family: 'Inter', system-ui, sans-serif;
}
}
/* Override for known fast connections (if you have this data) */
.net-fast body {
font-family: 'Inter', system-ui, sans-serif;
}
/* Save-data or slow connections stick with system fonts */
4. Font Loading Service Worker Strategy
// service-worker.js
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'font') {
// Cache fonts aggressively
event.respondWith(
caches.open('font-cache').then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
return cachedResponse || fetch(event.request).then((networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
});
})
);
}
});
5. Use font-display: optional with Background Prefetch
<!-- Try to use font immediately if available -->
<link rel="preload" as="font" href="/fonts/inter-regular.woff2" type="font/woff2" crossorigin>
<!-- But also prefetch for subsequent navigations -->
<link rel="prefetch" as="font" href="/fonts/inter-bold.woff2" type="font/woff2" crossorigin>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: optional; /* Only use if instantly available */
}
/* Bold variant prefetched for next navigation */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: optional;
}
🛡️ Prevention Checklist
Before Launching/New Feature
- [ ] All @font-face rules include font-display descriptor
- [ ] Critical fonts are preloaded with appropriate priority
- [ ] Fallback fonts are selected to minimize metric differences
- [ ] Considered using system-ui font family
- [ ] Tested on 4G/3G simulated throttling
- [ ] Tested with empty cache
- [ ] Verified CLS < 0.1 in lab and field data (when available)
- [ ] Checked for layout shifts in DevTools Performance panel
- [ ] Tested font failure scenarios (network errors, timeouts)
During Development
- [ ] Use font-display: optional during development to catch issues early
- [ ] Monitor CLS in development builds
- [ ] Test font-loading strategies early in component development
- [ ] Consider font metrics when choosing typefaces
- [ ] Document font loading decisions in component docs
For Ongoing Maintenance
- [ ] Audit new font additions for CLS impact
- [ ] Monitor field CLS data (CrUX, Web Vitals measurements)
- [ ] Review font-loading strategies quarterly
- [ ] Consider variable fonts to reduce font file count
- [ ] Stay updated on CSS font loading specification changes
🔧 Tools and Resources
Font Analysis Tools
- Dropbox Font Dropper - Visual font comparison
- Wakamai Fondue - Detailed font metadata viewer
- FontDrop! - Font file inspector
- Glyphhanger - Font subsetting and analysis
CLS Measurement
- web-vitals - Official metrics library
- PageSpeed Insights - Lab and field data
- Chrome User Experience Report - Real-user data
- Lighthouse - Lab audits
Font Loading Libraries
- Font Face Observer - Promise-based font loading
- Loader - Asynchronous CSS/JS loader
- Critical Font CSS - Critical CSS extraction
Browser Support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| font-display | 60+ | 58+ | 11.1+ | 79+ |
| size-adjust | 87+ | 88+ | 15.4+ | 87+ |
| ascent-override | 87+ | 88+ | 15.4+ | 87+ |
| Font Loading API | 41+ | 41+ | 10+ | 79+ |
| Preload as font | 50+ | 57+ | 10.1+ | 79+ |
Font-related CLS is one of the most preventable layout shift sources. By combining font-display: optional, proper fallback matching, strategic preloading, and systematic testing, you can eliminate font-related layout shifts while maintaining excellent typography.