UI/UX Atlas
Typography Intermediate

Web Fonts & Font Loading Performance (CLS)

Master the browser's font loading pipeline — from format choices and preloading to font-display strategies that eliminate layout shift and render-blocking delays.

7 min read

The full lesson

Every second a user waits for text to appear — or watches a paragraph jump as the real font replaces a placeholder — erodes trust and raises bounce rates. Font loading sits right at the intersection of design and engineering. The CSS choices you make directly determine whether a page passes Google’s Core Web Vitals. Once you understand how the browser loads and renders fonts, you can build pages that are both beautiful and fast.

Why Font Loading Causes Layout Shift

Cumulative Layout Shift (CLS) is a Core Web Vitals metric. It measures how much page content moves unexpectedly while the page loads. A score above 0.1 “needs improvement”; above 0.25 is “poor.”

Web fonts are one of the top CLS culprits. Here is why: the browser draws text in a fallback font while your real font downloads. When the real font arrives, the browser swaps it in. If the two fonts have different sizes or spacing, every line of text reflows. On a text-heavy page, that shift can be dramatic.

This plays out in two ways:

  • Flash of Invisible Text (FOIT) — the browser hides text entirely until the web font is ready. The user sees blank space, then text pops in.
  • Flash of Unstyled Text (FOUT) — the browser shows text in the fallback font right away, then swaps to the real font when it arrives. The user sees text immediately, but it may reflow.

Neither is perfect. The goal is to show text as fast as possible while keeping layout movement to a minimum.

The font-display Descriptor

font-display is the single most impactful CSS property for font loading. You put it inside a @font-face rule. It tells the browser how to balance “show text now” against “avoid a layout jump.”

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

The five values each handle the timing differently:

ValueBlock periodSwap periodBest for
autoBrowser default (usually block)InfiniteAvoid — unpredictable
block~3 s (invisible text)InfiniteIcon fonts only
swapMinimal (~0 ms)InfiniteBody text; accepts FOUT
fallback~100 ms~3 sBody text; limits swap window
optional~100 msNoneNon-critical decorative fonts

swap is the modern default for body text and UI fonts. It shows text immediately in the fallback font, then swaps to the real font when it is ready. Pair it with size-adjust (covered below) to reduce the visual jump.

optional is the right pick for decorative or display fonts that are genuinely supplemental. If the font does not arrive within a short window, the browser sticks with the fallback for that page load — and caches the font for next time.

Eliminating CLS with size-adjust and ascent-override

Since Chrome 92 and Firefox 89, CSS lets you tune a system fallback font so it matches your web font’s dimensions. When the real font arrives, the layout barely moves — because the fallback was already close to the right size.

@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter-fallback', sans-serif;
}

The exact values depend on the specific font pair — you need to measure them. Fortunately, tools like fontaine (a Nuxt/Vite plugin), next/font in Next.js, and the Automatic font fallback feature in Astro’s @astrojs/font integration calculate these overrides for you automatically. In 2026, hand-crafting these values yourself is an anti-pattern. Use the tooling.

Font Format: WOFF2 Is the Only Baseline You Need

As of 2026, WOFF2 has over 97% global browser support. The old pattern of loading .eot, .ttf, .woff, and .woff2 together is obsolete.

/* Modern — WOFF2 only */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
}

/* Outdated — unnecessary fallback chain */
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.eot?#iefix') format('embedded-opentype'),
       url('/fonts/myfont.woff2') format('woff2'),
       url('/fonts/myfont.woff') format('woff'),
       url('/fonts/myfont.ttf') format('truetype');
}

WOFF2 uses Brotli compression and is typically 30% smaller than WOFF, which was already smaller than raw TTF or OTF. Serving anything other than WOFF2 to modern browsers wastes bandwidth.

Preloading Critical Fonts

The browser only discovers @font-face rules after it has finished parsing the CSS. CSS is itself a render-blocking resource, so font loading can start later than you want. For above-the-fold text, that delay matters.

A link rel="preload" tag in the HTML head tells the browser to fetch the font right away, in parallel with HTML parsing — before it even sees your CSS.

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

The crossorigin attribute is required even for same-origin fonts. Font fetches use anonymous CORS. Without this attribute, the browser makes a duplicate request.

Preload the critical path only. If you preload every font file, you signal that everything is equally urgent — and the browser’s priority queue breaks down. A good rule: preload the body text font and any prominent heading font. Let decorative or icon fonts load normally.

Subsetting: Serving Only What You Need

A full typeface with Latin, Cyrillic, Greek, and extended character sets can exceed 500 KB. If your product only uses Latin characters, you can trim the font to a fraction of that size. This is called subsetting.

Google Fonts handles subsetting automatically via the text or subset parameters in its URL. When self-hosting, tools like glyphhanger, pyftsubset, or fonttools generate a subset WOFF2 from a list of characters.

The unicode-range descriptor lets the browser download subsets on demand:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-cyrillic.woff2') format('woff2');
  unicode-range: U+0400-045F, U+0490-0491;
}

The browser fetches only the subset files it needs for the characters actually on the page. A French homepage never downloads the Cyrillic subset. Google Fonts already does this in its CSS responses. You should replicate the same pattern when self-hosting.

Self-Hosting vs. Google Fonts

Google Fonts has gotten much faster since adding HTTP/2 push and font-display=swap support. But self-hosting still wins on performance for most teams, for two reasons:

  1. No extra DNS resolution — fetching from fonts.gstatic.com requires a DNS lookup, a TCP connection, and a TLS handshake. First-party requests skip all of that.
  2. Cache control — you control the Cache-Control headers, so you can set immutable long-term caching (max-age=31536000, immutable). Google Fonts defaults to max-age=86400.

Practical self-hosting stack in 2026: Download fonts from Google Fonts via google-webfonts-helper, run them through fonttools for subsetting, and host them on a CDN with long-lived cache headers. Or use a framework-level integration — Next.js @next/font and Astro @astrojs/font automate the entire pipeline, including fallback metric generation.

Measuring and Debugging Font Loading

Theory only gets you so far. Actual loading behavior depends on HTTP/2 multiplexing, caching state, and network conditions. Use these tools to see what is really happening:

  • Chrome DevTools Performance panel — the “Fonts” row in the waterfall shows when font fetches start and finish, relative to FCP and LCP. Watch for font requests that start late because the CSS was render-blocked.
  • WebPageTest — run a filmstrip view to see exactly when text appears and whether a FOUT swap is visible. Compare a “first visit” (cold cache) to a “repeat visit” (warm cache).
  • Lighthouse / CrUX — the CLS score in the Chrome User Experience Report (CrUX) reflects real-user layout shift, not lab data. A high CrUX CLS score when Lighthouse shows 0 often means slow connections are triggering a swap that fast connections never see.
  • font-display: optional and LCP — if a font is used on your LCP element and it loads late, it can hurt your LCP score even without causing CLS. In that case, the right fix is a preload tag, not just a font-display change.

Do

Preload the one or two critical font files used for body text and primary headings. Use font-display: swap paired with size-adjust overrides on the fallback font to minimize layout shift. Self-host WOFF2 files with long-lived cache headers and generate subsets for the character sets you actually use. Use variable fonts to cut file count.

Don't

Don’t load four format variants (eot, ttf, woff, woff2) for modern browsers — WOFF2 alone is enough. Don’t preload every font file on the page. Don’t use font-display: block for text fonts — hiding text for 3 seconds harms users more than a brief fallback reflow. Don’t skip measuring real-user CLS after deploying font changes.

The Modern Font Loading Checklist

Use this as a shipping checklist for any project:

  1. Format: WOFF2 only — no EOT, TTF, or bare WOFF for modern browsers.
  2. Variable fonts: One file per family axis set, covering the full weight range.
  3. Subsetting: Latin-only subsets for English-only products; unicode-range for multi-script.
  4. font-display: swap for all text fonts; optional for decorative fonts.
  5. Fallback metric overrides: size-adjust + ascent-override + descent-override — use tooling, not hand-crafting.
  6. Preload: Critical fonts only (body + primary heading) — use rel="preload" as="font" crossorigin.
  7. Caching: Cache-Control: max-age=31536000, immutable with content-hashed filenames.
  8. Verification: Run a WebPageTest filmstrip on a 4G-throttled connection before and after changes.

Skipping any one item is fine if you understand the trade-off. Skipping all of them — serving five static weight files from Google Fonts with no preload and no fallback tuning — is the most common cause of avoidable CLS on text-heavy production sites.