Font Loading Strategies That Eliminate CLS and Improve LCP (2026)

Master font loading strategies that fix CLS and LCP simultaneously. Compare font-display swap vs optional, size-adjust, WOFF2 preloading, and font subsetting.

BugViso

15 min read

A visitor lands on your product page. The hero headline renders in the browser's fallback font — Times New Roman on Windows, a serif face that's 14% wider per character than your intended Inter typeface. Three hundred milliseconds later, the custom WOFF2 file finishes downloading and the browser performs a font swap. The headline reflows, collapsing by 38 pixels in height, pushing the call-to-action button downward, and registering a 0.18 CLS shift that fails Google's Core Web Vitals threshold.

This single font swap event is responsible for more CLS violations than any other rendering behavior outside of ad injection. Worse, an improperly loaded web font can also delay LCP by blocking text rendering until the font file arrives — creating a scenario where both CLS and LCP fail from the same root cause.

This guide covers the exact font loading mechanics that control both metrics simultaneously, including font-display descriptor behavior, CSS size-adjust override calculations, WOFF2 preloading with crossorigin, and Unicode-range subsetting — each verified against Chromium's rendering pipeline.


How Browsers Load and Render Web Fonts: The Block-Swap-Failure Timeline

When a browser encounters a @font-face rule referencing an external font file, it enters a three-phase rendering timeline defined by the CSS Fonts Module Level 4 specification:

PhaseDurationRendering Behavior
Block Period0–3 seconds (varies by font-display)Text is invisible — the browser reserves layout space but paints nothing (FOIT)
Swap PeriodAfter block period endsText becomes visible in the fallback font; swaps to custom font when loaded
Failure PeriodAfter swap period expiresBrowser permanently uses the fallback font for the page's lifetime

The font-display descriptor in your @font-face rule controls how long each phase lasts. Choosing the wrong value creates either invisible text (blocking LCP) or visible text that reflowed (causing CLS).

The font-display Values Compared

css
/* Each font-display value controls the block/swap/failure timeline differently */

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-v13-latin-regular.woff2') format('woff2');
  font-display: swap;
  /* Block: ~100ms | Swap: infinite | Failure: never
     Result: Text visible immediately in fallback, swaps when ready.
     Risk: CLS from width/height difference between fallback and custom font */
}

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-v13-latin-regular.woff2') format('woff2');
  font-display: optional;
  /* Block: ~100ms | Swap: 0ms | Failure: immediate
     Result: If font isn't cached, fallback used permanently. No swap = no CLS.
     Risk: First-time visitors never see your custom font */
}

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-v13-latin-regular.woff2') format('woff2');
  font-display: fallback;
  /* Block: ~100ms | Swap: ~3s | Failure: after ~3s
     Result: Short swap window. Moderate CLS risk on slow connections */
}
font-display ValueBlock PeriodSwap PeriodCLS RiskLCP ImpactBest For
autoBrowser default (~3s)Browser defaultHigh (FOIT → FOUT)High (text invisible)Never use — unpredictable
block3 secondsInfiniteLow (if font loads in 3s)Very High (invisible text blocks LCP)Icon fonts only
swap~100msInfiniteHigh (reflow guaranteed)Low (text visible immediately)Body text when paired with size-adjust
fallback~100ms~3 secondsModerateLowBalanced approach
optional~100msNoneZeroLowMaximum CLS safety

💡 Engineering Rule of Thumb: Use font-display: optional when CLS is your primary failure metric. Use font-display: swap paired with size-adjust when brand typography must render on first visit. Never use font-display: block for body text — it creates invisible text that delays LCP by up to 3 seconds.


The size-adjust Fix: Eliminating CLS While Keeping font-display: swap

The CSS size-adjust descriptor (CSS Fonts Module Level 4) scales a fallback font's glyph sizes to match the custom font's metrics. When the browser swaps from fallback to custom font, the text occupies the same bounding box — producing zero layout shift.

Step 1: Measure Your Font Metrics

Every font has four critical vertical metrics and one horizontal scaling factor that determine how much space text occupies:

bash
# Install fonttools to extract font metrics
pip install fonttools

# Extract metrics from your WOFF2 file
python3 -c "
from fontTools.ttLib import TTFont
font = TTFont('inter-v13-latin-regular.woff2')
os2 = font['OS/2']
head = font['head']
hhea = font['hhea']

upm = head.unitsPerEm
ascent = os2.sTypoAscender
descent = abs(os2.sTypoDescender)
line_gap = os2.sTypoLineGap

print(f'Units Per Em: {upm}')
print(f'Ascent: {ascent}')
print(f'Descent: {descent}')
print(f'Line Gap: {line_gap}')
print(f'size-adjust: {upm / upm * 100}%')  # Baseline
"

Step 2: Calculate size-adjust for Your Fallback Stack

The size-adjust percentage scales the fallback font so its rendered text dimensions match the custom font:

css
/* ❌ Broken: No size-adjust — fallback Arial is ~5% wider than Inter */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-v13-latin-regular.woff2') format('woff2');
  font-display: swap;
}

body {
  font-family: 'Inter', Arial, sans-serif;
}
css
/* ✅ Fixed: size-adjust matched fallback eliminates CLS on swap */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-v13-latin-regular.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', sans-serif;
}

The four override descriptors work together:

DescriptorPurposeHow to Calculate
size-adjustScales the fallback font's glyph width to match custom font(custom avgCharWidth / fallback avgCharWidth) × 100%
ascent-overrideMatches the space above the baseline(custom ascent / custom UPM) × 100%
descent-overrideMatches the space below the baseline(custom descent / custom UPM) × 100%
line-gap-overrideMatches the extra space between lines(custom lineGap / custom UPM) × 100%

Step 3: Automate with Next.js or Nuxt

Modern frameworks calculate these overrides automatically:

typescript
// next.config.js — Next.js 13+ automatically generates size-adjust
// when using next/font with adjustFontFallback (enabled by default)
import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  // adjustFontFallback: true (default)
  // Generates an @font-face with size-adjust for Arial fallback
})
typescript
// Nuxt 3 — @nuxtjs/fontaine does the same calculation
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/fontaine'],
  fontMetrics: {
    fonts: ['Inter', { family: 'Roboto', src: '/fonts/Roboto-Regular.woff2' }]
  }
})

WOFF2 Preloading: Cutting LCP by Eliminating Discovery Delay

Without preloading, the browser discovers font files late in the rendering pipeline:

  1. HTML parsed → CSS file discovered → CSS parsed → @font-face src URL discovered → Font download begins

This waterfall means the font file download doesn't start until the CSS is fully parsed — often 200–400ms after the initial HTML response. For LCP elements using custom fonts, this delay directly inflates the LCP timestamp.

The Correct Preload Pattern

html
<!-- ✅ Preload the exact WOFF2 file used in your @font-face rule -->
<link
  rel="preload"
  href="/fonts/inter-v13-latin-regular.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

Critical implementation details:

  • The crossorigin attribute is mandatory even for same-origin fonts. The Fetch specification requires fonts to use CORS, and omitting crossorigin causes the browser to download the font twice — once from the preload (without CORS, discarded) and again from the @font-face rule (with CORS).
  • The href must exactly match the URL in your @font-face src. A single trailing slash difference causes a duplicate download.
  • The type="font/woff2" attribute allows browsers that don't support WOFF2 to skip the preload entirely.

Measuring the LCP Impact

bash
# Before preloading: measure font download timing
curl -w "DNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\nSize: %{size_download} bytes\n" \
  -o /dev/null -s https://example.com/fonts/inter-v13-latin-regular.woff2
typescript
// Verify preload effectiveness with PerformanceObserver
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name.includes('inter') && entry.name.includes('.woff2')) {
      console.log(`Font fetch start: ${entry.startTime.toFixed(0)}ms`)
      console.log(`Font fetch duration: ${entry.duration.toFixed(0)}ms`)
      console.log(`Transfer size: ${entry.transferSize} bytes`)
      // With preload: startTime ~50ms (parallel with HTML parse)
      // Without preload: startTime ~300ms (after CSS parse)
    }
  }
})
observer.observe({ type: 'resource', buffered: true })

💡 Engineering Rule of Thumb: Preload only fonts used above the fold on the initial render. Preloading fonts used only in the footer or modal dialogs wastes bandwidth priority and can delay more critical resources. Limit preloads to 1–2 font files maximum.


Unicode-Range Subsetting: Reducing Font File Size by 60–80%

A full Inter font file with Latin Extended, Cyrillic, Greek, and Vietnamese glyphs weighs ~100 KB in WOFF2. If your site only serves English content, you're shipping 60 KB of glyph data that will never render.

Subsetting With fonttools

bash
# Install pyftsubset (ships with fonttools)
pip install fonttools brotli

# Subset to Basic Latin + Latin-1 Supplement (covers English + Western European)
pyftsubset inter-v13-latin-regular.woff2 \
  --output-file=inter-latin-subset.woff2 \
  --flavor=woff2 \
  --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" \
  --layout-features='*'

# Compare file sizes
ls -la inter-v13-latin-regular.woff2 inter-latin-subset.woff2
# inter-v13-latin-regular.woff2  98,204 bytes
# inter-latin-subset.woff2       38,412 bytes  (61% reduction)

Using unicode-range for Multi-Script Sites

For sites serving multiple languages, use unicode-range to load only the glyph sets needed per page:

css
/* Latin glyphs — loaded on English pages */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-latin.woff2') format('woff2');
  font-display: swap;
  unicode-range: 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;
}

/* Cyrillic glyphs — loaded only when Cyrillic characters appear */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-cyrillic.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1,
                 U+2116;
}

When the browser encounters text containing only Latin characters, it downloads only the 38 KB Latin subset. Cyrillic glyphs are fetched on-demand only when a Cyrillic character appears in the DOM — a built-in browser optimization that requires zero JavaScript.


Self-Hosting vs Google Fonts CDN: The Performance Verdict

Google Fonts underwent a critical infrastructure change in 2023: Chrome partitioned its HTTP cache by top-level site. This means fonts loaded from fonts.googleapis.com on Site A provide zero cache benefit when the user visits Site B — destroying the cross-origin caching advantage that justified CDN-hosted fonts for a decade.

FactorSelf-Hosted WOFF2Google Fonts CDN
DNS resolution0ms (same origin)50–100ms (fonts.googleapis.com + fonts.gstatic.com)
TLS handshake0ms (reuses page connection)100–200ms (new connection to CDN)
Cache partitioningSame-origin cache (always hits)Partitioned per site (no cross-site benefit)
Preload compatibilityFull control, exact URL matchCannot preload — Google's CSS dynamically selects font files
font-display controlComplete (@font-face is yours)Partial (&display=swap parameter)
size-adjust controlFull (you write the fallback @font-face)None (Google's generated CSS doesn't include it)
bash
# Download Google Fonts for self-hosting
# Use google-webfonts-helper for optimized WOFF2 files
npx -y google-webfonts-helper -w "Inter:400,500,600,700" -o ./public/fonts

💡 Engineering Rule of Thumb: Self-host all production web fonts in 2026. The cross-origin caching advantage of Google Fonts CDN is gone due to cache partitioning, and self-hosting gives you full control over preloading, font-display, size-adjust, and subsetting — the four levers that control CLS and LCP.


The Complete Font Loading Stack: Combining All Optimizations

Here is a production-ready font loading configuration that addresses CLS and LCP simultaneously:

html
<!-- 1. Preload critical fonts (above-the-fold only) -->
<link rel="preload" href="/fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin />
css
/* 2. Define custom font with swap for immediate visibility */
@font-face {
  font-family: 'Inter';
  font-weight: 400;
  font-style: normal;
  src: url('/fonts/inter-latin-400.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
                 U+02C6, U+02DA, U+02DC, U+2000-206F;
}

@font-face {
  font-family: 'Inter';
  font-weight: 700;
  font-style: normal;
  src: url('/fonts/inter-latin-700.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
                 U+02C6, U+02DA, U+02DC, U+2000-206F;
}

/* 3. Size-adjusted fallback to eliminate CLS on 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%;
}

/* 4. Apply font stack with matched fallback */
body {
  font-family: 'Inter', 'Inter Fallback', -apple-system, BlinkMacSystemFont,
               'Segoe UI', sans-serif;
}
nginx
# 5. Nginx: Cache fonts aggressively and enable immutable
location ~* \.(woff2|woff)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "*";
    add_header X-Content-Type-Options "nosniff" always;
    gzip_static on;
}

Diagnosing font-induced layout shifts manually requires correlating PerformanceObserver entries with font download timings across multiple network conditions — a process that breaks down at scale when auditing 50+ pages.

BugViso's Core Web Vitals engine captures CLS and LCP on every crawled page using the official web-vitals library injected into a headless Chromium session. The audit surfaces:

  • Per-page CLS scores that flag pages exceeding the 0.1 threshold, isolating which pages suffer from font-related layout shifts versus ad injection or image reflow.
  • LCP element identification showing whether the Largest Contentful Paint target is a text node (affected by font loading) or an image element, and the exact LCP timestamp.
  • Network throttling simulation via the Advanced Speed & Performance Engine, which re-loads pages under Slow 3G (400ms RTT, 500 Kbps) and Fast 3G conditions — revealing font loading failures that only appear on constrained connections where WOFF2 downloads take 1–3 seconds.
  • Code coverage analysis via CDP precise CSS coverage tracking, identifying unused @font-face declarations and font files that are downloaded but never applied to any rendered text.

The multi-page crawl aggregates these findings site-wide, highlighting the pages with the worst CLS contributions and the heaviest font payloads — prioritized by impact rather than buried in per-page Lighthouse runs.


Common Font Loading Traps and Edge Cases

Trap 1: Preloading Fonts You Don't Use Above the Fold

Preloading a bold italic weight used only in blog article bodies wastes bandwidth priority. The browser fetches the preloaded font at high priority, competing with your hero image and critical CSS — potentially increasing LCP for the sake of a font that won't render until the user scrolls.

Trap 2: Using font-display: block for Body Text

The block value creates a 3-second invisible text window. If your LCP element is a <h1> or <p> using this font, LCP cannot fire until the font loads or the block period expires — whichever comes first. On Slow 3G, this alone can push LCP past 4 seconds.

This is the most common font preloading mistake. Without the crossorigin attribute, the preloaded font is fetched without CORS headers, and the @font-face rule triggers a second CORS-enabled fetch — resulting in a wasted download and no preload benefit.

Trap 4: Variable Fonts Without wght Range

Variable fonts are powerful but require explicit weight ranges in the @font-face declaration:

css
/* ❌ Broken: Missing weight range causes fallback on bold text */
@font-face {
  font-family: 'Inter Variable';
  src: url('/fonts/inter-variable.woff2') format('woff2-variations');
  font-display: swap;
}

/* ✅ Fixed: Declare the weight range the variable font supports */
@font-face {
  font-family: 'Inter Variable';
  src: url('/fonts/inter-variable.woff2') format('woff2-variations');
  font-display: swap;
  font-weight: 100 900;
}

Trap 5: Forgetting to Test on Windows

Windows renders fonts with ClearType/DirectWrite, which produces different glyph widths than macOS Core Text rendering. A size-adjust value calibrated on macOS may still produce CLS on Windows if the fallback font metrics differ between platforms. Always test with both Arial (Windows default sans-serif) and Helvetica (macOS default).


Frequently Asked Questions

Does font-display: optional actually prevent the custom font from ever loading?

No. font-display: optional allows the browser to use the custom font if it arrives within the ~100ms block period (which it does for cached fonts). On repeat visits, the cached WOFF2 loads in under 10ms and renders correctly. Only on the very first uncached visit will the fallback font persist — and even then, the browser pre-downloads the custom font for next time.

Should I preload both regular and bold weights?

Only preload the font weights that render above the fold on the initial viewport. If your hero section uses 400 (regular) and 700 (bold), preload both. If bold text only appears below the fold, skip the bold preload — the browser will discover and fetch it when it parses the CSS rule for below-fold content.

Is WOFF still needed as a fallback format?

In 2026, WOFF2 support is at 98%+ globally. The only browsers lacking WOFF2 support are IE11 (end of life) and extremely legacy Android WebView instances. Unless your analytics show meaningful traffic from these browsers, serving WOFF2 exclusively reduces configuration complexity and eliminates the risk of format negotiation bugs.

What CLS score does a font swap actually produce?

The CLS impact depends on the pixel difference between fallback and custom font rendering. A typical Arial → Inter swap without size-adjust shifts content by 15–40 pixels vertically for a full-width heading, producing CLS scores between 0.05 and 0.25. With properly calculated size-adjust, this drops to 0.001–0.005 — well within the "good" threshold.

Can I use font-display: swap for icon fonts?

No. Icon fonts should use font-display: block because a swapped icon font displays meaningless Unicode characters (squares or garbled text) during the fallback period. For icon fonts, the invisible text (block period) is preferable to displaying broken glyphs. Better yet, migrate from icon fonts to inline SVG sprites, which render immediately and never cause CLS.

How do I audit font loading performance across my entire site?

Running Lighthouse on individual pages doesn't scale. Use a site-wide crawl tool that captures CLS and LCP on every page under consistent conditions. A free BugViso audit runs the official web-vitals library across your entire site in a headless Chromium session, flagging font-related CLS contributors and LCP bottlenecks in a single aggregated report.

Found this useful? Share it.

See where your site stands

Run a free BugViso audit for SEO, speed, accessibility and AI search readiness — with fixes you can ship today.