CLS Font Loading

How to Fix CLS Caused by Font Loading

Web font loading is the most common hidden cause of Cumulative Layout Shift. When a browser downloads a custom font, it either hides text entirely (FOIT -- Flash of Invisible Text) or swaps from a fallback font to the custom font (FOUT -- Flash of Unstyled Text). Both patterns cause layout shifts because the fallback font and custom font have different metrics -- different character widths, line heights, and spacing.

This problem affects every framework and every site that uses custom fonts. The CLS impact is often 0.1-0.3 per font swap, which alone can push your score into the "poor" range. This guide covers five techniques to eliminate font-loading CLS, from quick wins to advanced approaches.

Font-related CLS is particularly insidious because it often does not appear in lab testing tools like Lighthouse (which use local font caches) but consistently affects real users on first visits.

Expected results

Following all steps in this guide typically produces these improvements:

Before

0.28

CLS score (Poor) -- web fonts cause visible text reflow and layout shifts on every page load

After

0.02

CLS score (Good) -- font-display swap with size-adjust eliminates visible layout shift

Step-by-step fix

Use font-display: swap with size-adjust

The font-display: swap property tells the browser to show text immediately using a fallback font, then swap to the custom font when it loads. By itself, this causes CLS because the fallback and custom fonts have different metrics. The size-adjust descriptor fixes this by scaling the fallback font to match the custom font's dimensions.

Browser support: size-adjust, ascent-override, descent-override, and line-gap-override are supported in all modern browsers (Chrome 87+, Firefox 89+, Safari 17+). For older browsers, the fallback font renders normally without the metric adjustments.
CSS -- Font face with size-adjust
/* Define the custom font */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
}

/* Override the fallback font to match Inter's metrics */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.64%;
  ascent-override: 90%;
  descent-override: 22.43%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter Fallback', sans-serif;
}
CSS -- Common font metric overrides
/* Fallback metrics for popular fonts */

/* Inter -> Arial fallback */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.64%;
  ascent-override: 90%;
  descent-override: 22.43%;
  line-gap-override: 0%;
}

/* Roboto -> Arial fallback */
@font-face {
  font-family: 'Roboto Fallback';
  src: local('Arial');
  size-adjust: 100.3%;
  ascent-override: 92.77%;
  descent-override: 24.41%;
  line-gap-override: 0%;
}

/* Open Sans -> Arial fallback */
@font-face {
  font-family: 'Open Sans Fallback';
  src: local('Arial');
  size-adjust: 105.27%;
  ascent-override: 101.23%;
  descent-override: 27.73%;
  line-gap-override: 0%;
}

Preload critical fonts

Font files are only discovered by the browser after it parses the CSS that references them. This means the font download does not start until after the HTML is parsed, the CSS is downloaded, and the CSS is parsed -- a chain of three sequential requests. Preloading moves the font download to the highest priority, starting it as soon as the HTML is parsed.

Only preload what you need: Preload only the font files used above the fold (typically 1-2 files). Over-preloading wastes bandwidth and can actually delay other critical resources. The crossorigin attribute is required even for same-origin fonts.
HTML -- Preload font files
<head>
  <!-- Preload only the fonts used above the fold -->
  <link
    rel="preload"
    href="/fonts/inter-var-latin.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />

  <!-- If you use a display font for headings -->
  <link
    rel="preload"
    href="/fonts/cabinet-grotesk-bold.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />

  <!-- Load the CSS after preloads -->
  <link rel="stylesheet" href="/styles/main.css" />
</head>

Self-host fonts instead of using Google Fonts CDN

Google Fonts requires a DNS lookup, TCP connection, and TLS handshake to fonts.googleapis.com, then another round-trip to fonts.gstatic.com for the actual font files. Self-hosting eliminates both external connections and lets you preload fonts directly. It also gives you full control over caching headers and font subsetting.

Bash -- Download and subset fonts
# Install google-webfonts-helper or use fontsource
npm install @fontsource/inter

# Or download manually and subset with fonttools
pip install fonttools brotli

# Subset to Latin characters only (reduces ~90KB to ~15KB)
pyftsubset inter-var.woff2 \
  --output-file=inter-var-latin.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga,calt' \
  --unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD'
CSS -- Self-hosted font declaration
/* Self-hosted with aggressive caching */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var-latin.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153,
    U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304,
    U+0308, U+0329, U+2000-206F;
}

/* Cache headers (in your server config or CDN rules):
   Cache-Control: public, max-age=31536000, immutable */

Use the CSS Font Loading API for precise control

The CSS Font Loading API lets you detect exactly when fonts finish loading and apply them with JavaScript. This gives you frame-perfect control over the font swap, letting you batch all font swaps into a single rendering update instead of triggering multiple sequential layout shifts.

JavaScript -- Font Loading API
// Load fonts programmatically and batch the swap
async function loadFonts() {
  // Check if fonts are already cached
  if (document.fonts.check('16px Inter')) {
    document.documentElement.classList.add('fonts-loaded');
    return;
  }

  try {
    // Load all font variants in parallel
    await Promise.all([
      document.fonts.load('400 16px Inter'),
      document.fonts.load('700 16px Inter'),
    ]);

    // Single class triggers all font swaps at once
    // This batches layout shifts into one frame
    document.documentElement.classList.add('fonts-loaded');

    // Remember for return visits
    sessionStorage.setItem('fonts-loaded', 'true');
  } catch (err) {
    // Fallback fonts remain -- no CLS at all
    console.warn('Font loading failed:', err);
  }
}

// On return visits, apply immediately
if (sessionStorage.getItem('fonts-loaded')) {
  document.documentElement.classList.add('fonts-loaded');
} else {
  loadFonts();
}
CSS -- Progressive font application
/* Fallback fonts (shown immediately, no layout shift) */
body {
  font-family: 'Inter Fallback', Arial, sans-serif;
}

h1, h2, h3 {
  font-family: 'Cabinet Fallback', Georgia, serif;
}

/* Custom fonts (applied all at once via JS class) */
.fonts-loaded body {
  font-family: 'Inter', 'Inter Fallback', Arial, sans-serif;
}

.fonts-loaded h1,
.fonts-loaded h2,
.fonts-loaded h3 {
  font-family: 'Cabinet Grotesk', 'Cabinet Fallback', Georgia, serif;
}

Framework-specific font loading implementations

Modern frameworks provide built-in font loading optimizations that handle size-adjust, preloading, and self-hosting automatically. Using these built-in solutions is the fastest path to zero font-related CLS.

TypeScript -- Next.js (next/font)
// app/layout.tsx
import { Inter } from 'next/font/google';
import localFont from 'next/font/local';

// Google font with automatic optimization
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',       // font-display: swap
  variable: '--font-body',
  // next/font automatically generates size-adjust
  // and self-hosts the font files
});

// Local font with explicit metric overrides
const cabinet = localFont({
  src: './fonts/CabinetGrotesk-Variable.woff2',
  display: 'swap',
  variable: '--font-display',
  adjustFontFallback: 'Arial', // Auto-generates fallback metrics
});

export default function RootLayout({ children }) {
  return (
    <html className={`${inter.variable} ${cabinet.variable}`}>
      <body>{children}</body>
    </html>
  );
}
JavaScript -- Nuxt 3 (@nuxtjs/fontaine)
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/fontaine'],

  // Fontaine automatically generates
  // fallback @font-face declarations
  // with correct metric overrides
  fontMetrics: {
    fonts: [
      { family: 'Inter', src: '/fonts/inter.woff2' },
      {
        family: 'Cabinet Grotesk',
        src: '/fonts/cabinet-grotesk.woff2',
      },
    ],
  },
});
Astro -- Font preloading
---
// src/layouts/Base.astro
---
<html>
  <head>
    <!-- Preload critical fonts -->
    <link
      rel="preload"
      href="/fonts/inter-var-latin.woff2"
      as="font"
      type="font/woff2"
      crossorigin
    />

    <!-- Inline critical font-face to avoid render-blocking CSS -->
    <style is:inline>
      @font-face {
        font-family: 'Inter';
        src: url('/fonts/inter-var-latin.woff2') format('woff2');
        font-display: swap;
        font-weight: 100 900;
      }
      @font-face {
        font-family: 'Inter Fallback';
        src: local('Arial');
        size-adjust: 107.64%;
        ascent-override: 90%;
        descent-override: 22.43%;
        line-gap-override: 0%;
      }
    </style>
  </head>
  <body>
    <slot />
  </body>
</html>

Quick checklist

  • All @font-face rules use font-display: swap
  • Fallback fonts have size-adjust and metric overrides matching custom fonts
  • Critical fonts (1-2 max) are preloaded with <link rel="preload">
  • Fonts are self-hosted (not loaded from external CDN)
  • Font files are subsetted to include only needed character ranges
  • Framework font module is used (next/font, @nuxtjs/fontaine, etc.)
  • Font swaps are batched using CSS Font Loading API or class-based approach

Frequently asked questions

A single font swap typically causes 0.05-0.15 CLS per occurrence, depending on the difference between fallback and custom font metrics. Sites loading 2-3 font weights without size-adjust commonly see 0.15-0.30 total CLS from fonts alone. With proper size-adjust overrides, font-related CLS drops to under 0.01.

font-display: optional eliminates CLS completely by never swapping fonts after the initial render. The tradeoff is that first-time visitors on slow connections may never see your custom font. It is a good choice for body text where the fallback font is acceptable, but less ideal for brand-critical display fonts. Consider using swap with size-adjust as the preferred approach.

Variable fonts reduce CLS indirectly by requiring only one font file instead of multiple weight-specific files. Fewer font files means fewer network requests and faster loading, which reduces the window where layout shifts can occur. A single variable font file also makes preloading simpler since you only need one preload link.

Use tools like the Fontaine library (npm package fontaine), the Font Style Matcher web tool, or the Next.js next/font module which calculates overrides automatically. The size-adjust value represents the ratio between your custom font's average character width and the fallback font's average character width. Getting this within 2-3% accuracy eliminates visible layout shift.

Google Fonts added font-display: swap support via a URL parameter (&display=swap), but it does not provide size-adjust overrides for fallback fonts. This means using Google Fonts with display=swap still causes CLS from the metric mismatch between your custom font and the system fallback. Self-hosting with metric overrides remains the most reliable solution.

Related resources