Fix Font Loading: FOIT, FOUT, and Layout Shift | OpsBlu Docs

Fix Font Loading: FOIT, FOUT, and Layout Shift

How to diagnose and fix web font loading performance issues. Covers font-display strategies, preloading, subsetting, self-hosting vs CDN, variable...

How Font Loading Affects Performance

Web fonts affect three Core Web Vitals:

  • LCP (Largest Contentful Paint) — If the largest element on the page is text styled with a web font, LCP is blocked until that font loads and the text renders.
  • CLS (Cumulative Layout Shift) — When a fallback font is swapped for a web font, different metrics (character width, line height, ascenders/descenders) cause text to reflow, shifting surrounding content.
  • FCP (First Contentful Paint) — Render-blocking font requests delay the first paint entirely.

FOIT vs FOUT

These are the two visible symptoms of slow font loading:

FOIT (Flash of Invisible Text) — Text is invisible while the font loads. The browser reserves space for text but shows nothing until the font arrives. This is the default behavior in Chrome/Edge/Firefox (they hide text for up to 3 seconds, then show fallback).

FOUT (Flash of Unstyled Text) — Fallback font shows immediately, then swaps to the web font when it loads. This is visible but doesn't block content — users can read the page sooner.

FOUT is almost always better than FOIT for user experience. font-display: swap opts into FOUT behavior.

Diagnosing Font Issues

Check Font Loading Waterfall

DevTools → Network → filter by "Font" type. Look for:

  1. When fonts start loading — If fonts don't start until late in the waterfall, they're being discovered late (need preload).
  2. How many font files load — Each weight/style is a separate file. Loading 6+ font files is a red flag.
  3. Where fonts load from — Third-party origins (fonts.gstatic.com, use.typekit.net) add DNS + connection overhead.
  4. File size — WOFF2 fonts should typically be 15–50 KB each. Over 100 KB suggests the font isn't subsetted.

Check for Layout Shift from Font Swap

DevTools → Performance → record a page load → look for layout shift markers. Click on a layout shift to see which elements moved. If text elements shift when fonts load, you have a font-swap CLS problem.

// Programmatically detect font-related layout shifts
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.value > 0.01) {
      entry.sources?.forEach(source => {
        if (source.node?.tagName) {
          console.log(`CLS from ${source.node.tagName}: ${entry.value.toFixed(3)}`);
        }
      });
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

Audit Font Usage

// List all fonts loaded by the page
document.fonts.forEach(font => {
  console.log(`${font.family} ${font.weight} ${font.style}: ${font.status}`);
});

// Check which fonts are actually used in rendering
// (fonts that loaded but aren't used are wasted bandwidth)
const usedFonts = new Set();
document.querySelectorAll('*').forEach(el => {
  usedFonts.add(getComputedStyle(el).fontFamily.split(',')[0].trim().replace(/"/g, ''));
});
console.log('Fonts used in DOM:', [...usedFonts]);

font-display Strategies

The font-display descriptor in @font-face controls what happens while the font is loading:

Value Block Period Swap Period Best For
auto ~3s (browser default) Infinite Never use — unpredictable
block 3s Infinite Icon fonts only (invisible squares look broken)
swap ~100ms Infinite Body text, headings (shows fallback immediately)
fallback ~100ms ~3s Good compromise — shows fallback, but stops swapping after 3s
optional ~100ms None Performance-critical pages — uses font only if already cached
/* Body/heading fonts: swap (show content immediately) */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

/* Icon font: block (invisible icons look broken) */
@font-face {
  font-family: 'Icons';
  src: url('/fonts/icons.woff2') format('woff2');
  font-display: block;
}

/* Optional: for performance-critical hero text on repeat visits */
@font-face {
  font-family: 'HeroFont';
  src: url('/fonts/hero.woff2') format('woff2');
  font-display: optional;
  /* First visit: shows fallback, caches font
     Return visits: font loads from cache instantly */
}

Google Fonts: Add display=swap

<!-- Without display parameter, Google Fonts defaults to font-display: auto (FOIT) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700" rel="stylesheet">

<!-- Add &display=swap to get font-display: swap in the generated CSS -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">

Reducing CLS from Font Swap

When font-display: swap shows a fallback font and then swaps in the web font, the different character metrics cause text to reflow. Fix this by matching the fallback font's metrics to the web font:

CSS size-adjust and Override Descriptors

/* Define a fallback that matches the web font's metrics */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;          /* Scale to match Inter's character width */
  ascent-override: 90%;       /* Match vertical metrics */
  descent-override: 22%;
  line-gap-override: 0%;
}

/* Use the adjusted fallback in the font stack */
body {
  font-family: 'Inter', 'Inter Fallback', sans-serif;
}

How to find the right values: Use the Font Style Matcher or Automatic Font Matching tools. Enter your web font and system fallback — they calculate the override values.

Next.js handles this automatically:

// next/font calculates size-adjust values at build time
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  // Next.js generates a fallback @font-face with
  // size-adjust, ascent-override, descent-override
  // automatically matched to Inter
});

Preloading Critical Fonts

Fonts referenced in CSS files are discovered late — the browser must download and parse the CSS before it knows about the font files. Preloading eliminates this delay:

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>

Rules for font preloading:

  • crossorigin is required even for same-origin fonts (fonts are always fetched with CORS).
  • type="font/woff2" prevents browsers that don't support WOFF2 from downloading the file.
  • Only preload fonts used above the fold. Preloading fonts used only in the footer wastes bandwidth.
  • Limit to 1–2 font files. Preloading too many saturates bandwidth that could load other critical resources.

Self-Hosting vs Google Fonts CDN

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

Advantages:

  • No third-party DNS lookup or connection overhead.
  • Preload works efficiently (same origin).
  • No privacy concerns (no requests to Google servers).
  • Full control over caching headers.
  • No dependency on Google's CDN availability.

How to self-host Google Fonts:

  1. Download from google-webfonts-helper — gives you optimized WOFF2 files and CSS.
  2. Or use the @fontsource npm packages: npm install @fontsource/inter.

Google Fonts CDN

Advantages:

  • Easy setup (one <link> tag).
  • Automatic subsetting by browser (Google serves different files based on Unicode ranges).

Disadvantages:

  • Two extra connections (fonts.googleapis.com + fonts.gstatic.com).
  • Cache partitioning in modern browsers means cached Google Fonts from other sites don't benefit your site (the old "shared cache" advantage no longer exists since Chrome 86+).
  • Privacy implications (requests to Google servers with user IP).

If you use Google Fonts CDN, always add preconnect:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

Reducing Font File Size

Subsetting

Most fonts include thousands of glyphs for multiple languages. If your site is English-only, subset to Latin characters:

# Using pyftsubset (from fonttools)
pip install fonttools brotli

# Subset to Latin characters only
pyftsubset Inter-Regular.ttf \
  --output-file=inter-regular-latin.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga,calt,dlig' \
  --unicodes="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"

# Result: typically 50-70% smaller than the full font

Variable Fonts

Variable fonts store multiple weights/styles in a single file. Instead of loading Regular + Bold + Italic (3 files, ~60 KB each = 180 KB), a variable font might be 80–120 KB for all weights:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;  /* Full weight range from one file */
  font-display: swap;
}

/* Use any weight, not just predefined steps */
h1 { font-weight: 650; }
body { font-weight: 380; }

When variable fonts are worth it: When you use 3+ weights of the same family. For 1–2 weights, individual static files are usually smaller.

WOFF2 Only

WOFF2 has 95%+ browser support. Unless you need to support IE11, you can drop WOFF and TTF formats entirely:

/* Don't do this: */
@font-face {
  src: url('font.woff2') format('woff2'),
       url('font.woff') format('woff'),
       url('font.ttf') format('truetype');
}

/* Do this: */
@font-face {
  src: url('font.woff2') format('woff2');
}

Font Loading API (Advanced)

For precise control over when and how fonts load:

// Load fonts programmatically
async function loadCriticalFonts() {
  const font = new FontFace('Inter', 'url(/fonts/inter-var.woff2)', {
    weight: '100 900',
    display: 'swap',
  });

  try {
    await font.load();
    document.fonts.add(font);
    document.documentElement.classList.add('fonts-loaded');
  } catch (err) {
    console.warn('Font failed to load:', err);
    // Fallback fonts are already visible thanks to font-display: swap
  }
}

// Wait until fonts are ready before showing font-dependent UI
document.fonts.ready.then(() => {
  console.log('All fonts loaded');
});