Font Lazy Loading — Deferring Web Font Loading for Performance
In this tutorial, you will learn about Font Lazy Loading. We cover key concepts, practical examples, and best practices to help you master this topic.
Font lazy loading defers web font loading until needed, reducing FOIT, blocking render time, and optimizing Core Web Vitals for text-heavy pages.
What You'll Learn
By the end of this tutorial, you'll understand the font loading lifecycle, how to use font-display to control rendering behavior, how to subset and preload critical fonts, and how to lazy-load non-critical font faces.
Why It Matters
Web fonts are render-blocking resources. By default, the browser hides text until the font loads (Flash of Invisible Text — FOIT) or shows a fallback font (Flash of Unstyled Text — FOUT). Both hurt user experience and Largest Contentful Paint. Font lazy loading techniques minimize these flashes and get text on screen faster.
Real-World Use
A content site uses Inter for body text and a decorative display font for headings. Inter is preloaded with font-display: swap so text appears immediately in a fallback font, then switches to Inter when loaded. The display font is lazy-loaded only when it enters the viewport, saving 150KB on initial page load.
Font Loading Lifecycle
graph LR
A[CSS declares @font-face] --> B[Browser checks local cache]
B -->|Cache hit| C[Render with font immediately]
B -->|Cache miss| D[Apply font-display strategy]
D -->|block| E[Hide text for up to 3s]
D -->|swap| F[Show fallback, swap when loaded]
D -->|fallback| G[Show fallback, short swap window]
D -->|optional| H[Use fallback if font not ready]
E --> I[Font downloads → render]
F --> I
G --> I
H --> I
style D fill:#4a90d9,color:#fff
style F fill:#27ae60,color:#fff
style H fill:#e74c3c,color:#fff
font-display Strategies
/* styles/fonts.css — Font loading strategies */
/* Block: hide text for up to 3 seconds while font loads */
@font-face {
font-family: 'DisplayFont';
src: url('/fonts/display.woff2') format('woff2');
font-display: block;
/* Use for brand-critical display text */
}
/* Swap: show fallback immediately, swap when font loads */
@font-face {
font-family: 'BodyFont';
src: url('/fonts/body.woff2') format('woff2');
font-display: swap;
/* Best for body text — readers see content immediately */
}
/* Fallback: short swap window, then use fallback permanently */
@font-face {
font-family: 'IconFont';
src: url('/fonts/icons.woff2') format('woff2');
font-display: fallback;
/* Icons are decorative — fallback is fine if font fails */
}
/* Optional: use font if already cached, otherwise skip */
@font-face {
font-family: 'DecorativeFont';
src: url('/fonts/decorative.woff2') format('woff2');
font-display: optional;
/* Non-critical decorative font — no one misses it */
}
Preloading Critical Fonts
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Font Preloading Strategy</title>
<!-- Preload the critical body font -->
<!-- crossorigin is REQUIRED for font preloads -->
<link rel="preload" href="/fonts/body.woff2" as="font" type="font/woff2" crossorigin>
<!-- Preload the display font used in hero -->
<link rel="preload" href="/fonts/display.woff2" as="font" type="font/woff2" crossorigin>
<!-- Preconnect to font CDN -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<style>
/* Critical CSS with fallback font stack */
body {
font-family: 'BodyFont', system-ui, -apple-system, sans-serif;
font-display: swap;
}
h1 {
font-family: 'DisplayFont', Georgia, serif;
}
</style>
</head>
<body>
<h1>This heading uses DisplayFont with serif fallback</h1>
<p>Body text uses BodyFont with system-ui fallback.</p>
</body>
</html>
Lazy Loading Non-Critical Fonts
// utils/font-loader.js — Lazy load fonts on demand
class FontLoader {
constructor() {
this.loadedFonts = new Set();
}
// Load a font dynamically
loadFont(family, config) {
if (this.loadedFonts.has(family)) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const font = new FontFace(family, `url(${config.url})`, {
style: config.style || 'normal',
weight: config.weight || '400',
display: config.display || 'swap'
});
font.load().then(() => {
document.fonts.add(font);
this.loadedFonts.add(family);
console.log(`Font loaded: ${family}`);
resolve(font);
}).catch(err => {
console.error(`Font failed to load: ${family}`, err);
reject(err);
});
});
}
// Load font when element enters viewport
loadFontOnIntersection(family, config, element) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadFont(family, config);
observer.disconnect();
}
});
}, { rootMargin: '200px' });
observer.observe(element);
}
// Load font on user interaction
loadFontOnInteraction(family, config, element, event = 'mouseenter') {
element.addEventListener(event, () => {
this.loadFont(family, config);
}, { once: true });
}
// Preload font for next page (prefetch)
prefetchFont(url) {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
link.as = 'font';
link.type = 'font/woff2';
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
}
}
const fontLoader = new FontLoader();
// Load critical fonts immediately
fontLoader.loadFont('BodyFont', {
url: '/fonts/body.woff2',
weight: '400',
display: 'swap'
});
// Load decorative font when heading enters viewport
const heading = document.querySelector('.hero-heading');
fontLoader.loadFontOnIntersection('DisplayFont', {
url: '/fonts/display.woff2',
weight: '700',
display: 'swap'
}, heading);
// Load icon font when use clicks the menu
const menuButton = document.getElementById('menu-btn');
fontLoader.loadFontOnInteraction('IconFont', {
url: '/fonts/icons.woff2',
weight: '400',
display: 'fallback'
}, menuButton, 'click');
Font Subsetting
/* Only include the characters you need — dramatically reduces file size */
/* Full Latin character set — ~30KB */
@font-face {
font-family: 'BodyFont';
src: url('/fonts/body-latin.woff2') format('woff2');
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;
font-display: swap;
}
/* Cyrillic subset — ~25KB */
@font-face {
font-family: 'BodyFont';
src: url('/fonts/body-cyrillic.woff2') format('woff2');
unicode-range: U+0400-04FF, U+0500-052F, U+2DE0-2DFF, U+A640-A69F;
font-display: swap;
}
/* Japanese subset (kanji only) — ~50KB vs 500KB full */
@font-face {
font-family: 'JPBodyFont';
src: url('/fonts/jp-body-common.woff2') format('woff2');
unicode-range: U+4E00-9FFF, U+3040-309F, U+30A0-30FF;
font-display: swap;
}
Performance Measurement
// Measure font loading impact
async function measureFontPerformance() {
// Wait for fonts to be ready
await document.fonts.ready;
const metrics = {};
// Check font load times via Performance API
const fontResources = performance.getEntriesByType('resource')
.filter(r => r.initiatorType === 'css' || r.name.includes('.woff'));
metrics.fonts = fontResources.map(f => ({
name: f.name.split('/').pop(),
duration: `${f.duration.toFixed(0)}ms`,
size: `${f.transferSize} bytes`
}));
// Check LCP impact
const paintEntries = performance.getEntriesByType('paint');
const lcpEntry = performance.getEntriesByType('largest-contentful-paint');
metrics.LCP = lcpEntry.length > 0
? `${lcpEntry[0].renderTime.toFixed(0)}ms`
: 'Not measured';
// Measure FOIT/FOUT duration
const fontBlockingTime = fontResources
.filter(f => f.duration > 100)
.reduce((total, f) => total + f.duration, 0);
metrics.fontBlockingTime = `${fontBlockingTime.toFixed(0)}mstotal`;
metrics.recommendation = fontBlockingTime > 500
? 'Consider preloading fonts or using font-display: swap'
: 'Font loading is well optimized';
return metrics;
}
// Usage
measureFontPerformance().then(console.table);
Common Mistakes
- Missing crossorigin on font preloads. Font files from CDNs require the crossorigin attribute on preload links. Without it, the preload is ignored and the font downloads twice.
- Using font-display: block on body text. Block hides text for up to 3 seconds. Use swap for body text so users can start reading immediately with fallback fonts.
- Not subsetting fonts. Full font files often include characters for many languages. Subset to the scripts you actually use and save 50-90% of font size.
- Loading all font weights and styles. Each weight is a separate file. If you load 300, 400, 500, 600, and 700, that's five font files. Load only the weights your design actually uses.
- Including decorative fonts in critical CSS. A decorative font used in a footer below the fold doesn't need to block initial render. Lazy load it with font-display: optional.
Practice Questions
- What is the difference between font-display: swap and font-display: fallback?
- Why do font preloads need the crossorigin attribute?
- How does font subsetting reduce file size?
- What is FOIT and how does font-display: swap prevent it?
- How can you measure the performance impact of fonts on LCP?
Challenge: Create a font loading Strategy for a multilingual site with Latin (30KB), Cyrillic (25KB), and Arabic (20KB) fonts. Preload the Latin font used for hero text, lazy load the Arabic and Cyrillic subsets when content in those scripts enters the viewport, measure the font blocking time, and ensure the text remains readable during loading.
FAQ
Mini Project
Build a font loading dashboard: create a page that loads 4+ fonts with different font-display strategies, measures FOIT duration, font load times, and LCP impact for each strategy, visualizes the results in a comparison table, and recommends the optimal font loading strategy for different font types.
What's Next
You've mastered font lazy loading. Next, learn about CSS Lazy Loading to defer non-critical stylesheets and reduce render-blocking CSS.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro