Front-end Engineering Lab

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

ValueBehaviorUse Case
swapShow fallback immediately, swap when loadedRecommended for most cases
optionalUse custom font only if cachedPerformance-critical apps
fallbackShort invisible period (100ms), then swapBalance between FOIT and FOUT
blockHide text until font loads (up to 3s)❌ Avoid - causes FOIT
autoBrowser decidesNot 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

  1. Use font-display: swap: Always
  2. Preload critical fonts: 1-2 fonts maximum
  3. Use WOFF2: 30% smaller than WOFF
  4. Subset fonts: Include only used characters
  5. Consider variable fonts: One file for all weights
  6. Match fallback metrics: Use size-adjust to prevent CLS
  7. 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.

On this page