PatternsCore Optimizations
Font Loading Strategy
Optimize web font loading to eliminate FOIT and improve perceived performance with font-display swap.
Font Loading Strategy
Problem
Web fonts cause FOIT (Flash of Invisible Text) where text is invisible for 3+ seconds while fonts load. On slow connections, users see blank pages.
Solution
Use font-display: swap to show fallback fonts immediately, then swap when custom fonts load. For critical control, use Font Face Observer API.
/**
* Font loading with font-display
*/
const fontDisplayCSS = `
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap; /* Show fallback immediately */
}
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font-bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
`;
/**
* Font Face Observer implementation
*/
class FontLoader {
private loadedFonts = new Set<string>();
/**
* Load font with timeout
*/
public async load(
family: string,
options: { weight?: string; style?: string; timeout?: number } = {}
): Promise<void> {
const key = `${family}-${options.weight || '400'}-${options.style || 'normal'}`;
if (this.loadedFonts.has(key)) {
return;
}
const descriptor: FontFaceDescriptors = {
weight: options.weight || '400',
style: options.style || 'normal',
};
try {
await document.fonts.load(`${descriptor.weight} 16px ${family}`, '', {
timeout: options.timeout || 3000,
});
this.loadedFonts.add(key);
document.documentElement.classList.add(`font-${family.toLowerCase()}-loaded`);
} catch (error) {
console.warn(`Font ${family} failed to load, using fallback`);
}
}
/**
* Load multiple fonts in parallel
*/
public async loadAll(fonts: Array<{ family: string; weight?: string; style?: string }>): Promise<void> {
await Promise.all(fonts.map((font) => this.load(font.family, font)));
}
/**
* Preload critical fonts
*/
public preload(fonts: Array<{ family: string; url: string; weight?: string }>): void {
fonts.forEach((font) => {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'font';
link.type = 'font/woff2';
link.href = font.url;
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
});
}
/**
* Check if font is loaded
*/
public isLoaded(family: string): boolean {
return document.fonts.check(`16px ${family}`);
}
}
/**
* FOUT (Flash of Unstyled Text) manager
*/
class FOUTManager {
private fontLoader = new FontLoader();
constructor() {
// Add loading class
document.documentElement.classList.add('fonts-loading');
}
public async loadCriticalFonts(): Promise<void> {
try {
await this.fontLoader.loadAll([
{ family: 'CustomFont', weight: '400' },
{ family: 'CustomFont', weight: '700' },
]);
// Fonts loaded successfully
document.documentElement.classList.remove('fonts-loading');
document.documentElement.classList.add('fonts-loaded');
} catch (error) {
// Timeout or error - remove loading class anyway
document.documentElement.classList.remove('fonts-loading');
document.documentElement.classList.add('fonts-failed');
}
}
public preloadFonts(): void {
this.fontLoader.preload([
{
family: 'CustomFont',
url: '/fonts/custom-font.woff2',
weight: '400',
},
{
family: 'CustomFont',
url: '/fonts/custom-font-bold.woff2',
weight: '700',
},
]);
}
}
/**
* Subsetting strategy
*/
interface SubsetOptions {
characters: string;
unicodeRanges?: string[];
}
function generateSubsetCSS(fontName: string, subset: SubsetOptions): string {
return `
@font-face {
font-family: '${fontName}';
src: url('/fonts/${fontName}-subset.woff2') format('woff2');
font-display: swap;
unicode-range: ${subset.unicodeRanges?.join(', ') || 'U+0020-007F'};
}
`;
}
/**
* Variable fonts strategy
*/
const variableFontCSS = `
@font-face {
font-family: 'InterVariable';
src: url('/fonts/Inter-Variable.woff2') format('woff2');
font-weight: 100 900; /* Full weight range */
font-display: swap;
}
/* Use variable font */
body {
font-family: 'InterVariable', sans-serif;
font-weight: 400;
}
h1 {
font-weight: 700; /* No additional file needed */
}
`;CSS Implementation
/* System font stack as fallback */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
}
/* Custom font with fallback */
.fonts-loaded body {
font-family: 'CustomFont', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* Prevent layout shift with size-adjust */
@font-face {
font-family: 'CustomFont-Fallback';
src: local('Arial');
size-adjust: 95%; /* Match custom font metrics */
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
}
/* Hide text while loading (optional, use sparingly) */
.fonts-loading .critical-text {
visibility: hidden;
}
.fonts-loaded .critical-text,
.fonts-failed .critical-text {
visibility: visible;
}HTML Setup
<!DOCTYPE html>
<html lang="en" class="fonts-loading">
<head>
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/custom-font.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<style>
/* Inline critical font CSS */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
</style>
</head>
<body>
<h1>Content is visible immediately</h1>
<script>
// Load fonts
const foutManager = new FOUTManager();
foutManager.loadCriticalFonts();
</script>
</body>
</html>font-display Values
| Value | Behavior | Use Case |
|---|---|---|
swap | Show fallback immediately, swap when loaded | Recommended for most cases |
optional | Use custom font only if cached | Performance-critical apps |
fallback | Short invisible period (100ms), then swap | Balance between FOIT and FOUT |
block | Hide text until font loads (up to 3s) | ❌ Avoid - causes FOIT |
auto | Browser decides | Not predictable |
Performance Impact
Before Optimization
- LCP: 4.2s (text invisible)
- CLS: 0.25 (layout shift when font swaps)
- User experience: Blank screen
After Optimization
- LCP: 1.8s (57% faster)
- CLS: 0.05 (size-adjust prevents shift)
- User experience: Immediate content visibility
Best Practices
- Use font-display: swap: Always
- Preload critical fonts: 1-2 fonts maximum
- Use WOFF2: 30% smaller than WOFF
- Subset fonts: Include only used characters
- Consider variable fonts: One file for all weights
- Match fallback metrics: Use size-adjust to prevent CLS
- Self-host fonts: Faster than CDN (no extra DNS lookup)
When to Use Each Strategy
✅ font-display: swap:
- All web fonts
- Best balance of performance and UX
✅ Font Face Observer:
- Critical branding fonts
- When you need precise control
- Progressive enhancement
✅ Variable fonts:
- Multiple weights needed
- Modern browser target
- Reduced HTTP requests
Impact on Millions of Users
For an app with 10M daily users:
- Without optimization: 42M seconds of blank screens (11,667 hours)
- With optimization: Content visible immediately
- Saved time: 11,667 hours of user time per day
Font loading strategy is non-negotiable for user-facing applications at scale.
Usage
// Initialize font loading
const foutManager = new FOUTManager();
// Preload fonts (in <head>)
foutManager.preloadFonts();
// Load when ready
document.addEventListener('DOMContentLoaded', () => {
foutManager.loadCriticalFonts();
});This pattern ensures text is always visible, even on the slowest connections.