Font Loading Architecture & Rendering Performance
Web fonts are deceptively complex. A single font family can add 500KB+ to your page weight, block rendering for seconds on slow connections, cause layout shifts when swapping from fallback to custom fonts, and create inconsistent experiences across browsers. At scale, poor font loading architecture impacts every page view—millions of users experiencing flash of invisible text (FOIT), flash of unstyled text (FOUT), or cumulative layout shift from font swapping.
This deep dive covers the mechanics of font loading, the tradeoffs between different loading strategies, and the production patterns used to deliver custom typography without sacrificing performance.
Scale Context
| Metric | Value |
|---|---|
| Daily Page Views | 100M |
| Font Files Served | 200M/day |
| Unique Font Families | 12 |
| Total Font Weight (unoptimized) | 2.4MB |
| Total Font Weight (optimized) | 180KB |
| Font-related CLS (before) | 0.15 |
| Font-related CLS (after) | 0.02 |
| FOIT Duration (target) | 0ms |
┌─────────────────────────────────────────────────────────────────────┐
│ FONT LOADING IMPACT │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Font Load Time Distribution (3G): │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ P50: ████████ 800ms │ │
│ │ P75: ████████████ 1.2s │ │
│ │ P90: ████████████████ 2.1s │ │
│ │ P99: ████████████████████████ 4.5s │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ CLS Contribution by Source: │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Font Swap: ████████████████ 45% │ │
│ │ Images: ████████████ 30% │ │
│ │ Ads: ████████ 20% │ │
│ │ Dynamic Content: ██ 5% │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
GIF via GIPHY
Font Loading Mechanics
Browser Font Loading Behavior
┌─────────────────────────────────────────────────────────────────────────────┐
│ FONT LOADING TIMELINE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. CSS Parsed │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ @font-face { │ │
│ │ font-family: 'Custom Font'; │ │
│ │ src: url('/fonts/custom.woff2') format('woff2'); │ │
│ │ } │ │
│ │ body { font-family: 'Custom Font', sans-serif; } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 2. Font Request Triggered (on first use) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Browser discovers text needs 'Custom Font' │ │
│ │ Initiates font download │ │
│ │ ⚠️ Font NOT loaded at @font-face parse - only when used! │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 3. Block Period (font-display dependent) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ font-display: block → 3s invisible text (FOIT) │ │
│ │ font-display: swap → 0ms invisible, immediate fallback (FOUT) │ │
│ │ font-display: fallback → 100ms invisible, then fallback │ │
│ │ font-display: optional → 100ms invisible, may skip custom font │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 4. Swap Period │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ If font loads during swap period: swap to custom font │ │
│ │ ⚠️ Swap causes text reflow = potential CLS │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 5. Font Applied │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Custom font rendered │ │
│ │ Different metrics than fallback = layout shift │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Font-Display Strategies
/* Block: Invisible text until font loads (up to 3s) */
/* Use case: Brand fonts where FOUT is unacceptable */
@font-face {
font-family: 'Brand Font';
src: url('/fonts/brand.woff2') format('woff2');
font-display: block;
}
/* Swap: Show fallback immediately, swap when ready */
/* Use case: Body text where content visibility matters */
@font-face {
font-family: 'Body Font';
src: url('/fonts/body.woff2') format('woff2');
font-display: swap;
}
/* Fallback: Short block (100ms), swap window, then stick with fallback */
/* Use case: Balance between FOIT and FOUT */
@font-face {
font-family: 'UI Font';
src: url('/fonts/ui.woff2') format('woff2');
font-display: fallback;
}
/* Optional: Short block, no swap period - browser decides */
/* Use case: Performance-critical, nice-to-have fonts */
@font-face {
font-family: 'Optional Font';
src: url('/fonts/optional.woff2') format('woff2');
font-display: optional;
}
GIF via GIPHY
Font Loading State Machine
// Font loading states and transitions
type FontLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
interface FontLoadingStatus {
family: string;
state: FontLoadingState;
loadTime?: number;
error?: Error;
}
class FontLoadingTracker {
private fonts: Map<string, FontLoadingStatus> = new Map();
private observers: Set<(status: FontLoadingStatus) => void> = new Set();
constructor() {
this.initTracking();
}
private initTracking(): void {
// Use document.fonts API
if ('fonts' in document) {
document.fonts.ready.then(() => {
this.onAllFontsLoaded();
});
// Track individual font loads
document.fonts.addEventListener('loadingdone', (event: FontFaceSetLoadEvent) => {
for (const fontFace of event.fontfaces) {
this.updateFontStatus(fontFace.family, 'loaded');
}
});
document.fonts.addEventListener('loadingerror', (event: FontFaceSetLoadEvent) => {
for (const fontFace of event.fontfaces) {
this.updateFontStatus(fontFace.family, 'failed');
}
});
}
}
async loadFont(
family: string,
options: { weight?: string; style?: string } = {}
): Promise<FontLoadingStatus> {
const fontSpec = `${options.style || 'normal'} ${options.weight || '400'} 16px "${family}"`;
this.updateFontStatus(family, 'loading');
const startTime = performance.now();
try {
await document.fonts.load(fontSpec);
const loadTime = performance.now() - startTime;
const status: FontLoadingStatus = {
family,
state: 'loaded',
loadTime,
};
this.fonts.set(family, status);
this.notifyObservers(status);
return status;
} catch (error) {
const status: FontLoadingStatus = {
family,
state: 'failed',
error: error as Error,
};
this.fonts.set(family, status);
this.notifyObservers(status);
return status;
}
}
// Check if font is already loaded
isFontLoaded(family: string): boolean {
try {
return document.fonts.check(`16px "${family}"`);
} catch {
return false;
}
}
// Wait for specific fonts
async waitForFonts(families: string[], timeout: number = 3000): Promise<Map<string, FontLoadingStatus>> {
const results = new Map<string, FontLoadingStatus>();
const loadPromises = families.map(async (family) => {
const timeoutPromise = new Promise<FontLoadingStatus>((resolve) => {
setTimeout(() => {
resolve({ family, state: 'failed', error: new Error('Timeout') });
}, timeout);
});
const loadPromise = this.loadFont(family);
const result = await Promise.race([loadPromise, timeoutPromise]);
results.set(family, result);
});
await Promise.all(loadPromises);
return results;
}
subscribe(callback: (status: FontLoadingStatus) => void): () => void {
this.observers.add(callback);
return () => this.observers.delete(callback);
}
private updateFontStatus(family: string, state: FontLoadingState): void {
const existing = this.fonts.get(family);
const status: FontLoadingStatus = {
...existing,
family,
state,
};
this.fonts.set(family, status);
this.notifyObservers(status);
}
private notifyObservers(status: FontLoadingStatus): void {
for (const observer of this.observers) {
observer(status);
}
}
private onAllFontsLoaded(): void {
// All declared fonts have loaded
document.documentElement.classList.add('fonts-loaded');
}
}
// React hook for font loading state
function useFontLoading(families: string[]): {
loaded: boolean;
loadedFonts: Set<string>;
failedFonts: Set<string>;
} {
const [loadedFonts, setLoadedFonts] = useState<Set<string>>(new Set());
const [failedFonts, setFailedFonts] = useState<Set<string>>(new Set());
const tracker = useMemo(() => new FontLoadingTracker(), []);
useEffect(() => {
const unsubscribe = tracker.subscribe((status) => {
if (families.includes(status.family)) {
if (status.state === 'loaded') {
setLoadedFonts(prev => new Set([...prev, status.family]));
} else if (status.state === 'failed') {
setFailedFonts(prev => new Set([...prev, status.family]));
}
}
});
// Check already loaded fonts
for (const family of families) {
if (tracker.isFontLoaded(family)) {
setLoadedFonts(prev => new Set([...prev, family]));
}
}
return unsubscribe;
}, [families, tracker]);
const loaded = families.every(f => loadedFonts.has(f) || failedFonts.has(f));
return { loaded, loadedFonts, failedFonts };
}
Font Optimization Techniques
Subsetting
// Font subsetting reduces file size by including only needed characters
// Can reduce a 500KB font to <30KB
interface SubsetConfig {
input: string;
output: string;
unicodeRanges: UnicodeRange[];
features?: OpenTypeFeature[];
hinting?: boolean;
}
type UnicodeRange =
| 'basic-latin' // U+0000-007F (ASCII)
| 'latin-extended' // U+0080-024F
| 'punctuation' // U+2000-206F
| 'currency' // U+20A0-20CF
| 'custom';
type OpenTypeFeature = 'kern' | 'liga' | 'calt' | 'smcp' | 'onum' | 'tnum';
// Subsetting strategies per use case
const subsetStrategies: Record<string, SubsetConfig> = {
// Headlines: Limited character set
headlines: {
input: '/fonts/display-full.woff2',
output: '/fonts/display-headlines.woff2',
unicodeRanges: ['basic-latin'],
features: ['kern', 'liga'],
hinting: false, // Display fonts at large sizes don't need hinting
},
// Body text: Extended character set
body: {
input: '/fonts/body-full.woff2',
output: '/fonts/body.woff2',
unicodeRanges: ['basic-latin', 'latin-extended', 'punctuation', 'currency'],
features: ['kern', 'liga', 'calt'],
hinting: true,
},
// Numbers only: For data displays
numbers: {
input: '/fonts/mono-full.woff2',
output: '/fonts/mono-numbers.woff2',
unicodeRanges: ['custom'], // 0-9, ., ,, %, $, €, etc.
features: ['tnum'], // Tabular numbers
hinting: true,
},
};
// Generate @font-face with unicode-range for automatic subsetting
function generateUnicodeRangeFontFace(family: string, baseUrl: string): string {
const ranges = [
{ range: 'U+0000-00FF', suffix: 'latin', weight: '400' },
{ range: 'U+0100-024F', suffix: 'latin-ext', weight: '400' },
{ range: 'U+0370-03FF', suffix: 'greek', weight: '400' },
{ range: 'U+0400-04FF', suffix: 'cyrillic', weight: '400' },
];
return ranges.map(({ range, suffix, weight }) => `
@font-face {
font-family: '${family}';
font-style: normal;
font-weight: ${weight};
font-display: swap;
src: url('${baseUrl}-${suffix}.woff2') format('woff2');
unicode-range: ${range};
}
`).join('\n');
}
// Google Fonts URL with text parameter for inline subsetting
function generateGoogleFontsUrl(family: string, text: string): string {
const encodedText = encodeURIComponent(text);
return `https://fonts.googleapis.com/css2?family=${family}&text=${encodedText}&display=swap`;
}
Variable Fonts
// Variable fonts: One file for multiple weights/widths/styles
// Can replace 5-10 static font files
interface VariableFontAxes {
wght?: [number, number]; // Weight: 100-900
wdth?: [number, number]; // Width: 50-200
slnt?: [number, number]; // Slant: -12 to 0
ital?: [0, 1]; // Italic: 0 or 1
opsz?: [number, number]; // Optical size
}
// @font-face for variable font
const variableFontFace = `
@font-face {
font-family: 'Inter Variable';
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
font-weight: 100 900;
font-stretch: 75% 125%;
font-style: oblique 0deg 10deg;
font-display: swap;
}
`;
// CSS usage with variable font
const variableFontStyles = `
/* Use any weight value, not just 400, 700 */
.light { font-weight: 300; }
.regular { font-weight: 400; }
.medium { font-weight: 500; }
.semibold { font-weight: 600; }
.bold { font-weight: 700; }
/* Smooth weight animations */
.animate-weight {
transition: font-weight 0.3s ease;
}
.animate-weight:hover {
font-weight: 700;
}
/* Optical sizing for readability */
.small-text {
font-size: 12px;
font-variation-settings: 'opsz' 12;
}
.large-text {
font-size: 48px;
font-variation-settings: 'opsz' 48;
}
`;
// Size comparison: Static vs Variable
interface FontSizeComparison {
staticFonts: {
files: number;
totalSize: number;
weights: string[];
};
variableFont: {
files: number;
totalSize: number;
weights: string;
};
savings: number;
}
const sizeComparison: FontSizeComparison = {
staticFonts: {
files: 8,
totalSize: 320000, // 320KB
weights: ['300', '400', '500', '600', '700', '300i', '400i', '700i'],
},
variableFont: {
files: 1,
totalSize: 95000, // 95KB
weights: '100-900 + italics',
},
savings: 0.70, // 70% smaller
};
GIF via GIPHY
Preloading Critical Fonts
// Font preloading strategies
class FontPreloader {
// Preload critical fonts in document head
static generatePreloadLinks(fonts: CriticalFont[]): string {
return fonts.map(font => `
<link
rel="preload"
href="${font.url}"
as="font"
type="font/${font.format}"
crossorigin="anonymous"
${font.media ? `media="${font.media}"` : ''}
/>
`).join('\n');
}
// Preload only above-the-fold fonts
static getCriticalFonts(route: string): CriticalFont[] {
// Route-specific critical fonts
const routeFonts: Record<string, CriticalFont[]> = {
'/': [
{ url: '/fonts/display-700.woff2', format: 'woff2' }, // Hero headline
{ url: '/fonts/body-400.woff2', format: 'woff2' }, // Body text
],
'/blog': [
{ url: '/fonts/body-400.woff2', format: 'woff2' },
{ url: '/fonts/body-400-italic.woff2', format: 'woff2' },
],
'/docs': [
{ url: '/fonts/mono-400.woff2', format: 'woff2' }, // Code blocks
{ url: '/fonts/body-400.woff2', format: 'woff2' },
],
};
return routeFonts[route] || routeFonts['/'];
}
// Preconnect to font CDN
static generatePreconnect(origins: string[]): string {
return origins.map(origin => `
<link rel="preconnect" href="${origin}" crossorigin />
<link rel="dns-prefetch" href="${origin}" />
`).join('\n');
}
}
interface CriticalFont {
url: string;
format: 'woff2' | 'woff' | 'truetype';
media?: string;
}
// Next.js font optimization (built-in)
// next/font automatically:
// 1. Self-hosts fonts (no external requests)
// 2. Preloads critical fonts
// 3. Generates optimal @font-face
// 4. Applies size-adjust for zero CLS
// Usage:
/*
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export default function RootLayout({ children }) {
return (
<html className={inter.variable}>
<body>{children}</body>
</html>
);
}
*/
CLS Prevention with Font Metrics
Size-Adjust for Fallback Matching
// size-adjust makes fallback font metrics match custom font
// Eliminates layout shift when font swaps
interface FontMetrics {
unitsPerEm: number;
ascent: number;
descent: number;
lineGap: number;
xHeight: number;
capHeight: number;
}
class FontMetricsCalculator {
// Calculate size-adjust to match fallback to custom font
calculateSizeAdjust(customMetrics: FontMetrics, fallbackMetrics: FontMetrics): number {
// Size adjust = custom font size / fallback font size to achieve same visual size
// Based on x-height ratio
const customXHeightRatio = customMetrics.xHeight / customMetrics.unitsPerEm;
const fallbackXHeightRatio = fallbackMetrics.xHeight / fallbackMetrics.unitsPerEm;
return (fallbackXHeightRatio / customXHeightRatio) * 100;
}
// Calculate ascent/descent overrides
calculateAscentOverride(customMetrics: FontMetrics, fallbackMetrics: FontMetrics): number {
return (customMetrics.ascent / customMetrics.unitsPerEm) /
(fallbackMetrics.ascent / fallbackMetrics.unitsPerEm) * 100;
}
calculateDescentOverride(customMetrics: FontMetrics, fallbackMetrics: FontMetrics): number {
return (customMetrics.descent / customMetrics.unitsPerEm) /
(fallbackMetrics.descent / fallbackMetrics.unitsPerEm) * 100;
}
calculateLineGapOverride(customMetrics: FontMetrics, fallbackMetrics: FontMetrics): number {
return (customMetrics.lineGap / customMetrics.unitsPerEm) /
(fallbackMetrics.lineGap / fallbackMetrics.unitsPerEm) * 100;
}
// Generate optimized @font-face with fallback overrides
generateFontFace(config: FontConfig): string {
const overrides = this.calculateAllOverrides(config.customMetrics, config.fallbackMetrics);
return `
/* Custom font */
@font-face {
font-family: '${config.family}';
src: url('${config.url}') format('woff2');
font-display: swap;
}
/* Adjusted fallback font */
@font-face {
font-family: '${config.family} Fallback';
src: local('${config.fallbackFamily}');
size-adjust: ${overrides.sizeAdjust.toFixed(2)}%;
ascent-override: ${overrides.ascentOverride.toFixed(2)}%;
descent-override: ${overrides.descentOverride.toFixed(2)}%;
line-gap-override: ${overrides.lineGapOverride.toFixed(2)}%;
}
/* Usage: Custom font with matched fallback */
body {
font-family: '${config.family}', '${config.family} Fallback', ${config.fallbackFamily};
}
`;
}
private calculateAllOverrides(
customMetrics: FontMetrics,
fallbackMetrics: FontMetrics
): FontOverrides {
return {
sizeAdjust: this.calculateSizeAdjust(customMetrics, fallbackMetrics),
ascentOverride: this.calculateAscentOverride(customMetrics, fallbackMetrics),
descentOverride: this.calculateDescentOverride(customMetrics, fallbackMetrics),
lineGapOverride: this.calculateLineGapOverride(customMetrics, fallbackMetrics),
};
}
}
interface FontConfig {
family: string;
url: string;
fallbackFamily: string;
customMetrics: FontMetrics;
fallbackMetrics: FontMetrics;
}
interface FontOverrides {
sizeAdjust: number;
ascentOverride: number;
descentOverride: number;
lineGapOverride: number;
}
// Pre-calculated metrics for common font pairings
const commonFontMetrics: Record<string, FontMetrics> = {
'Inter': {
unitsPerEm: 2048,
ascent: 1984,
descent: -494,
lineGap: 0,
xHeight: 1118,
capHeight: 1490,
},
'Arial': {
unitsPerEm: 2048,
ascent: 1854,
descent: -434,
lineGap: 67,
xHeight: 1062,
capHeight: 1467,
},
'system-ui': {
// Approximate values for system fonts
unitsPerEm: 2048,
ascent: 1900,
descent: -500,
lineGap: 0,
xHeight: 1100,
capHeight: 1450,
},
};
// Example generated CSS for Inter with Arial fallback
const interWithArialFallback = `
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: swap;
}
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107.64%;
ascent-override: 96.88%;
descent-override: 24.15%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter Fallback', Arial, sans-serif;
}
`;
FOUT Optimization
GIF via GIPHY
// Strategies to minimize Flash of Unstyled Text impact
class FOUTOptimizer {
// Strategy 1: Critical FOFT (Flash of Faux Text)
// Load subset first, full font second
static generateFOFTStyles(family: string, baseUrl: string): string {
return `
/* Stage 1: Minimal subset (loads fast) */
@font-face {
font-family: '${family}';
src: url('${baseUrl}-subset.woff2') format('woff2');
font-display: swap;
unicode-range: U+0041-005A, U+0061-007A, U+0030-0039; /* A-Z, a-z, 0-9 */
}
/* Stage 2: Full character set (loads later) */
@font-face {
font-family: '${family}';
src: url('${baseUrl}-full.woff2') format('woff2');
font-display: swap;
}
/* Browser loads subset first, then enhances with full */
`;
}
// Strategy 2: Font Loading API with class toggle
static async loadWithClassToggle(fonts: FontToLoad[]): Promise<void> {
// Remove class during loading (use fallback)
document.documentElement.classList.remove('fonts-loaded');
try {
// Load all fonts
await Promise.all(
fonts.map(font =>
document.fonts.load(`${font.weight} 1em "${font.family}"`)
)
);
// Add class when loaded (triggers swap)
document.documentElement.classList.add('fonts-loaded');
// Store in localStorage to skip FOUT on repeat visits
localStorage.setItem('fonts-loaded', 'true');
} catch (error) {
console.error('Font loading failed:', error);
// Still add class to unblock rendering
document.documentElement.classList.add('fonts-loaded');
}
}
// Strategy 3: Use local fonts for instant display
static generateLocalFirstFontFace(family: string, webFontUrl: string): string {
return `
@font-face {
font-family: '${family}';
src: local('${family}'), /* Try installed font first */
local('${family} Regular'),
url('${webFontUrl}') format('woff2'); /* Fall back to web font */
font-display: swap;
}
`;
}
}
interface FontToLoad {
family: string;
weight: string;
}
// CSS for font loading states
const fontLoadingCSS = `
/* Before fonts load: Use adjusted fallback */
html {
font-family: 'Inter Fallback', system-ui, sans-serif;
}
/* After fonts load: Use custom font */
html.fonts-loaded {
font-family: 'Inter', system-ui, sans-serif;
}
/* Optional: Fade in effect when font loads */
html:not(.fonts-loaded) body {
opacity: 0.99; /* Prevents flash, minimal impact */
}
html.fonts-loaded body {
opacity: 1;
transition: opacity 0.1s ease;
}
`;
// Inline script for immediate font check (goes in <head>)
const fontCheckScript = `
<script>
// Check if fonts were previously loaded
if (localStorage.getItem('fonts-loaded') === 'true') {
document.documentElement.classList.add('fonts-loaded');
}
// Also check if fonts are already cached
if ('fonts' in document) {
document.fonts.ready.then(function() {
if (document.fonts.check('1em Inter')) {
document.documentElement.classList.add('fonts-loaded');
localStorage.setItem('fonts-loaded', 'true');
}
});
}
</script>
`;
Self-Hosting vs CDN
Self-Hosting Architecture
// Self-hosting fonts: Full control, better performance, privacy compliance
interface SelfHostConfig {
fontsDir: string;
outputDir: string;
fonts: FontDefinition[];
optimization: {
subset: boolean;
woff2Only: boolean;
preload: boolean;
};
}
interface FontDefinition {
family: string;
source: string; // Local path or Google Fonts URL
weights: (string | number)[];
styles: ('normal' | 'italic')[];
subsets: string[];
}
class FontSelfHostingPipeline {
constructor(private config: SelfHostConfig) {}
async process(): Promise<ProcessedFontResult> {
const results: ProcessedFontResult = {
fonts: [],
css: '',
preloadLinks: [],
totalSize: 0,
};
for (const font of this.config.fonts) {
// Download or copy font files
const fontFiles = await this.collectFontFiles(font);
// Convert to WOFF2 if needed
const woff2Files = await this.convertToWoff2(fontFiles);
// Subset if configured
const finalFiles = this.config.optimization.subset
? await this.subsetFonts(woff2Files, font.subsets)
: woff2Files;
// Generate @font-face CSS
const css = this.generateFontFaceCSS(font, finalFiles);
// Add to results
results.fonts.push(...finalFiles);
results.css += css;
results.totalSize += finalFiles.reduce((sum, f) => sum + f.size, 0);
// Generate preload links for critical fonts
if (this.config.optimization.preload) {
const criticalFiles = this.getCriticalFiles(finalFiles);
results.preloadLinks.push(
...criticalFiles.map(f => this.generatePreloadLink(f))
);
}
}
return results;
}
private generateFontFaceCSS(font: FontDefinition, files: FontFile[]): string {
let css = '';
for (const file of files) {
css += `
@font-face {
font-family: '${font.family}';
font-style: ${file.style};
font-weight: ${file.weight};
font-display: swap;
src: url('${file.path}') format('woff2');
${file.unicodeRange ? `unicode-range: ${file.unicodeRange};` : ''}
}
`;
}
return css;
}
private generatePreloadLink(file: FontFile): string {
return `<link rel="preload" href="${file.path}" as="font" type="font/woff2" crossorigin />`;
}
private async collectFontFiles(font: FontDefinition): Promise<FontFile[]> {
return [];
}
private async convertToWoff2(files: FontFile[]): Promise<FontFile[]> {
return files;
}
private async subsetFonts(files: FontFile[], subsets: string[]): Promise<FontFile[]> {
return files;
}
private getCriticalFiles(files: FontFile[]): FontFile[] {
// Return regular weight fonts (400, 700)
return files.filter(f =>
(f.weight === 400 || f.weight === 700) &&
f.style === 'normal'
);
}
}
interface FontFile {
path: string;
family: string;
weight: number;
style: 'normal' | 'italic';
size: number;
unicodeRange?: string;
}
interface ProcessedFontResult {
fonts: FontFile[];
css: string;
preloadLinks: string[];
totalSize: number;
}
CDN Considerations
GIF via GIPHY
// When to use Google Fonts vs self-hosting
interface FontCDNComparison {
googleFonts: {
pros: string[];
cons: string[];
bestFor: string[];
};
selfHosted: {
pros: string[];
cons: string[];
bestFor: string[];
};
}
const comparison: FontCDNComparison = {
googleFonts: {
pros: [
'Easy setup',
'Automatic format selection',
'Potential cross-site caching (limited since Chrome 86)',
'Free CDN bandwidth',
'Large font library',
],
cons: [
'Privacy concerns (sends referrer to Google)',
'GDPR compliance issues in EU',
'Extra DNS lookup + connection',
'No control over font-display',
'Dependent on third-party availability',
],
bestFor: [
'Prototypes',
'Low-traffic sites',
'Sites not targeting EU',
'Quick experiments',
],
},
selfHosted: {
pros: [
'Full control over loading strategy',
'No third-party dependency',
'GDPR/privacy compliant',
'Same-origin = faster (no extra connection)',
'Can subset to exact needs',
'Works offline',
],
cons: [
'More setup required',
'Must handle format conversion',
'Uses your bandwidth/CDN',
'Manual updates for new font versions',
],
bestFor: [
'Production applications',
'Privacy-conscious sites',
'Sites targeting EU (GDPR)',
'Performance-critical applications',
'Enterprise applications',
],
},
};
// GDPR-compliant Google Fonts alternative
// Download fonts and self-host
async function downloadGoogleFonts(fontUrl: string, outputDir: string): Promise<void> {
// 1. Fetch CSS with different user-agents to get all formats
const userAgents = {
woff2: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
};
for (const [format, ua] of Object.entries(userAgents)) {
const response = await fetch(fontUrl, {
headers: { 'User-Agent': ua },
});
const css = await response.text();
// 2. Extract font URLs from CSS
const fontUrls = css.match(/url\(([^)]+)\)/g) || [];
// 3. Download each font file
for (const urlMatch of fontUrls) {
const url = urlMatch.slice(4, -1);
const fontResponse = await fetch(url);
const fontData = await fontResponse.arrayBuffer();
// 4. Save to output directory
const filename = url.split('/').pop();
// Save fontData to outputDir/filename
}
// 5. Rewrite CSS with local paths
const localCss = css.replace(
/url\(https:\/\/[^)]+\/([^)]+)\)/g,
`url(/fonts/$1)`
);
// Save localCss to outputDir/fonts.css
}
}
Production Incidents & Lessons
Incident 1: Font Loading Timeout on Slow Networks
Symptoms: Users on 3G saw no text for 10+ seconds, then fallback font.
Root Cause: font-display: block with large font files.
// Solution: font-display: optional for slow connections
class AdaptiveFontLoading {
getFontDisplay(): 'swap' | 'optional' {
// Check connection type
const connection = (navigator as any).connection;
if (connection) {
// Use optional for slow connections (fallback only, no swap)
if (connection.effectiveType === '2g' || connection.effectiveType === 'slow-2g') {
return 'optional';
}
// Use optional if user has data saver enabled
if (connection.saveData) {
return 'optional';
}
}
// Default to swap for good connections
return 'swap';
}
generateAdaptiveCSS(family: string, url: string): string {
const display = this.getFontDisplay();
return `
@font-face {
font-family: '${family}';
src: url('${url}') format('woff2');
font-display: ${display};
}
`;
}
}
Incident 2: CLS Spike from Font Swap
GIF via GIPHY
Symptoms: CLS jumped from 0.02 to 0.18 after font redesign.
Root Cause: New font had significantly different metrics than fallback.
// Solution: Comprehensive font metric matching
class FontMetricAnalyzer {
async analyzeMetricMismatch(
customFontUrl: string,
fallbackFamily: string
): Promise<MetricMismatchReport> {
// Load fonts
const customMetrics = await this.extractMetrics(customFontUrl);
const fallbackMetrics = await this.measureFallback(fallbackFamily);
// Calculate differences
const differences = {
xHeight: Math.abs(customMetrics.xHeight - fallbackMetrics.xHeight),
capHeight: Math.abs(customMetrics.capHeight - fallbackMetrics.capHeight),
ascent: Math.abs(customMetrics.ascent - fallbackMetrics.ascent),
descent: Math.abs(customMetrics.descent - fallbackMetrics.descent),
};
// Calculate expected CLS
const expectedCLS = this.estimateCLS(differences);
return {
customMetrics,
fallbackMetrics,
differences,
expectedCLS,
recommendation: this.getRecommendation(expectedCLS, differences),
};
}
private estimateCLS(differences: MetricDifferences): number {
// Simplified CLS estimation based on metric differences
// Real CLS depends on text amount and layout
const maxDiff = Math.max(
differences.xHeight,
differences.capHeight,
differences.ascent
);
// Each 10% height difference ~= 0.05 CLS contribution
return (maxDiff / 100) * 0.5;
}
private getRecommendation(expectedCLS: number, differences: MetricDifferences): string {
if (expectedCLS < 0.05) {
return 'Metrics are well-matched. No action needed.';
}
if (expectedCLS < 0.1) {
return 'Apply size-adjust and ascent/descent overrides to fallback.';
}
return 'Consider a different fallback font with closer metrics.';
}
private async extractMetrics(fontUrl: string): Promise<FontMetrics> {
return {} as FontMetrics;
}
private async measureFallback(family: string): Promise<FontMetrics> {
return {} as FontMetrics;
}
}
interface MetricMismatchReport {
customMetrics: FontMetrics;
fallbackMetrics: FontMetrics;
differences: MetricDifferences;
expectedCLS: number;
recommendation: string;
}
interface MetricDifferences {
xHeight: number;
capHeight: number;
ascent: number;
descent: number;
}
Tradeoffs & Engineering Decisions
Decision: font-display Strategy
| Strategy | FOIT | FOUT | CLS Risk | Best For |
|---|---|---|---|---|
block | 3s max | None | Low | Brand-critical text |
swap | None | Yes | High | Body text, content |
fallback | 100ms | Brief | Medium | Balanced approach |
optional | 100ms | None | None | Performance-critical |
Decision: Variable vs Static Fonts
| Factor | Variable Font | Static Fonts |
|---|---|---|
| File Size (single weight) | Larger | Smaller |
| File Size (5+ weights) | Smaller | Larger |
| Browser Support | Modern only | Universal |
| Flexibility | Any weight value | Fixed weights |
| Animation | Smooth weight transitions | No transitions |
GIF via GIPHY
Decision: Preload Strategy
| Fonts to Preload | Impact |
|---|---|
| 0 | Fastest initial paint, FOIT risk |
| 1-2 (critical) | Good balance |
| 3+ | Delays other resources |
Recommendation: Preload only fonts used above-the-fold, max 2 files.
Conclusion
Font loading is one of the most impactful yet overlooked aspects of frontend performance. Key principles:
-
Subset aggressively: Most sites need <100 characters, not 3000+.
-
Match metrics for zero CLS: Use size-adjust and override properties.
-
Preload critical fonts only: 1-2 files maximum in the critical path.
-
Choose font-display wisely:
swapfor content,optionalfor slow networks. -
Self-host for production: Better performance, full control, privacy compliance.
GIF via GIPHY
The patterns in this guide can reduce font-related page weight by 90%+ and eliminate font-related CLS entirely. The specific implementation depends on your font choices and performance requirements, but the principles remain constant: load less, load smart, and match your fallbacks.
References
- Web Fonts Guide: https://web.dev/learn/design/typography
- font-display: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display
- Variable Fonts: https://web.dev/variable-fonts/
- Font Loading API: https://developer.mozilla.org/en-US/docs/Web/API/CSS_Font_Loading_API
GIF via GIPHYWhat did you think?