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.
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.