Font Loading and Text Rendering

Web fonts sit on the critical path twice: once as a network resource the browser must fetch before it can paint final text, and again as a layout input whose metrics differ from the fallback, causing text to reflow when the real font swaps in. This topic covers how @font-face fetch timing, font-display, FOUT/FOIT behavior, preload, and metric overrides shape both First Contentful Paint and Cumulative Layout Shift. It is part of Browser Rendering Pipeline Fundamentals.

A web font fetch does not start when the browser sees the @font-face rule β€” it starts when the render tree first matches an element to that font family. By then the CSSOM is already built, the DOM is parsed, and layout is about to run. The font request therefore races against first paint, and the loser of that race is visible to the user as either invisible text or a flash of swapped glyphs.

Timeline of font fetch against first paint and the font-display swap The font fetch starts when an element first matches the font family. With font-display swap the browser paints fallback text at first paint, then swaps to the web font when the fetch completes, producing a layout shift. Network Paint Font match woff2 download Font ready FCP (fallback) Swap + shift

When the Fetch Starts

The font request is lazy by design. The sequence is: parse HTML into DOM nodes, build the CSSOM, generate the render tree, and only when a render-tree node’s computed font-family resolves to a declared @font-face does the browser queue the download. This avoids fetching fonts that no rendered element uses, but it pushes the request to the worst possible moment β€” after the CSSOM round-trip is already paid.

<!-- Without preload: fetch waits for CSSOM + render tree + font match -->
<link rel="stylesheet" href="/css/app.css">  <!-- declares @font-face -->

<!-- With preload: fetch starts during HTML parse, in parallel with CSS -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font"
      type="font/woff2" crossorigin>          <!-- crossorigin is mandatory for fonts -->
<link rel="stylesheet" href="/css/app.css">

Preload moves the request forward by a full round-trip, but it does not change the paint policy β€” that is font-display’s job. The crossorigin attribute is required even for same-origin fonts because font fetches are always made in CORS mode; omitting it causes a duplicate, non-preloaded request.

FOUT vs FOIT

Two failure modes describe what the user sees while a font is loading. FOIT (Flash of Invisible Text) hides text entirely until the font arrives β€” the layout reserves space but renders nothing. FOUT (Flash of Unstyled Text) paints fallback text immediately, then re-renders in the web font once it loads. FOIT delays content visibility; FOUT shows content sooner but introduces a visible swap and usually a layout shift. The font-display descriptor selects which behavior you get, with a default of block that produces FOIT.

font-display block period swap period user-visible result
auto up to ~3s infinite engine default, usually FOIT
block ~3s infinite FOIT, then swaps to web font
swap 0ms infinite FOUT, fallback shown immediately
fallback ~100ms ~3s brief FOIT, then fallback locks if late
optional ~100ms 0ms font used only if cached/near-instant

The choice between these is detailed in Preventing FOUT and FOIT with font-display. The short rule: swap for body text where reading speed matters, optional for fonts whose absence is cosmetically acceptable, and never the default block for above-the-fold copy.

Fonts as a Render-Blocking and Layout-Shift Source

A font does not block the first paint the way a stylesheet does β€” the browser will paint fallback text under swap. But under the default block, text inside the affected elements is invisible for up to three seconds, which directly delays the largest text node and can wreck Largest Contentful Paint. And whenever the swap fires, the fallback and web font almost never share the same cap-height, x-height, and advance widths, so lines re-wrap and blocks change height. That movement is recorded by the layout instability algorithm as Cumulative Layout Shift.

[Main Thread β€” font swap on body copy]
 0ms          | FCP β€” fallback (Arial) painted, layout height = 1840px
 1180ms       | Inter woff2 arrives, render-tree nodes re-matched
 1182–1191ms  | Recalculate Style + Layout (9ms) β€” block reflows to 1792px
 1191ms       | Paint β€” 48px upward shift, CLS += 0.07

A 0.07 shift from a single font swap is enough to fail the 0.1 CLS threshold once other shifts are added. Reducing layout shift from web fonts covers the metric-override techniques that collapse this reflow to zero, and you can watch the shift land live with the Layout Instability API.

Aligning Fallback Metrics

The reflow on swap exists because the fallback font occupies a different amount of vertical and horizontal space than the web font. The @font-face metric overrides let you reshape a fallback so it matches the web font’s box, eliminating the geometry delta the swap would otherwise cause.

/* Before: fallback Arial is shorter and narrower than Inter β€” swap reflows */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
}

/* After: a metric-matched fallback that occupies Inter's exact box */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107%;        /* scale glyphs so x-height matches Inter */
  ascent-override: 90%;     /* pin the ascent so line boxes are identical */
  descent-override: 22%;    /* pin the descent for matching line height */
  line-gap-override: 0%;    /* remove extra leading the fallback would add */
}

Setting font-family: 'Inter', 'Inter Fallback', sans-serif then renders fallback text in a box that is dimensionally identical to the real font. When Inter swaps in, glyph shapes change but no box changes size, so the swap produces zero layout shift. This is the same principle behind the framework β€œfont fallback” tooling β€” Next.js next/font and Fontaine compute these overrides automatically.

Validation

Confirm the fetch starts early and the swap costs nothing with the Font Loading API and a layout-shift observer:

// Fires once the web font is usable β€” measure against FCP
document.fonts.ready.then(() => {
  performance.mark('fonts-ready')
})

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // recent-input shifts are user-driven; only unexpected shifts count
    if (!entry.hadRecentInput && entry.value > 0) {
      console.warn('Layout shift:', entry.value.toFixed(4))
    }
  }
}).observe({ type: 'layout-shift', buffered: true })
Metric Target How measured
Font request start During HTML parse Network panel initiator = preload
document.fonts.ready < 1.0s on Fast 4G User Timing mark vs FCP
CLS from font swap 0.00 layout-shift entries during swap window
LCP (text node) < 2.5s Web Vitals overlay

Cross-check these against the broader critical-path budget in Critical Rendering Path Optimization: a font that preloads early but still swaps with a visible reflow means the metric overrides, not the fetch timing, are the remaining problem.