Font Loading & Text Rendering Performance: Painting Text Inside the LCP Budget

This guide extends the rendering-pipeline work in Core Web Vitals & Measurement into the one subsystem that can fail two metrics with a single request: the web font.

A font is unusual among page resources because it is discovered late, blocks paint by default, and changes text geometry when it finally arrives. On a text-first page the Largest Contentful Paint element is usually a heading or paragraph, so the font sits directly on the LCP critical path — the browser will not paint that text until the face resolves or its block period expires. Then, at the moment it does resolve, every glyph is remeasured, and if the fallback occupied a different amount of space the surrounding content jumps, feeding Cumulative Layout Shift. One under-specified @font-face rule can push LCP past 2.5s and CLS past 0.1 at the same time.

The font display timeline against the LCP budget A time axis from zero to four seconds showing a 100 millisecond block period where text is invisible, a swap period where the fallback is painted, and the swap moment where the layout shifts, all compared against the 2.5 second LCP threshold. One font request, two failing metrics 0s 1s 2s 2.5s 4s LCP budget 2.5s block swap period — fallback painted, text is readable failure period — fallback is permanent display Text first paints here — this timestamp is your text LCP Font arrives, glyphs remeasured — every line below moves (CLS)

Both failures are avoidable, and neither is fixed by "loading the font faster" alone. The work is to shorten discovery, choose a display strategy that matches the content's role, and make the fallback occupy the same box as the real face so the swap is invisible.

The Metric Damage a Font Actually Does

Quantify the two failure modes before touching a config, because they have different owners and different fixes.

Text LCP delay. With the default font-display: auto (which Chromium treats as block), the browser hides text for up to three seconds while waiting for the face. On a fast connection this is invisible; on a p75 mobile connection the font is often still in flight at second two, and because the LCP candidate is that hidden text, LCP is not recorded until the swap. The damage lands entirely in the render-delay phase — the phase that a bigger CDN or a smaller image cannot help.

Swap shift. When the fallback and the web font have different per-glyph advance widths and line heights, the paragraph reflows on arrival. A single hero paragraph re-wrapping from three lines to four moves everything beneath it by one line height. A shift of a 900px-wide, 24px-tall block on a 1280×720 viewport contributes roughly 0.02 to CLS; three such blocks on one page is a fifth of the entire budget spent on typography.

The two interact in a way that traps teams: switching to font-display: swap removes the invisible-text delay and improves LCP, then immediately introduces the shift that block was accidentally suppressing. Fixing one without the other just moves the failure between metrics.

Prerequisites

  • Chrome 124+ (or any current Chromium) with the Network, Performance and Rendering panels, plus the Layout Shift Regions overlay for visual confirmation of swap-induced movement.
  • The font files themselves, self-hosted. Third-party font CSS adds a connection and a redirect before the face is even discovered — see preloading Google Fonts without blocking render for the exact ordering when you cannot self-host.
  • WOFF2 builds for every face you actually ship. If your pipeline still emits WOFF or TTF alongside, confirm the src list puts WOFF2 first — every current browser takes it.
  • A metrics-capable subsetting tool (fonttools/pyftsubset, glyphhanger, or a build-time equivalent) and a way to read the fallback's metrics — capsize or the browser's own document.fonts measurements both work.

1. Inventory What You Are Actually Loading

Start from what the browser fetched, not from what the stylesheet declares. Load the page with an empty cache, filter the Network panel to Font, and record three numbers per file: transferred bytes, start time, and whether the request was initiated by a stylesheet or a preload.

bash
# Every font the page requested, in request order, with size and timing.
npx lighthouse https://example.com --output=json --quiet \
  | jq -r '.audits["network-requests"].details.items[]
           | select(.resourceType=="Font")
           | "\(.startTime|floor)ms  \(.transferSize)B  \(.url)"'
# trade-off: Lighthouse's simulated throttle inflates absolute timings. Use this
# for the ORDER and the byte counts; take real timestamps from field data.

Two findings are common and both are quick wins. The first is faces nobody uses: an italic and a 300-weight shipped because the type foundry's snippet included them, downloaded on every visit for text that never renders in them. The second is duplicate formats, where a legacy WOFF is fetched alongside the WOFF2 because the src list is ordered oldest-first.

2. Capture a Baseline for Both Metrics

Attribute the current damage before changing anything, so you can prove the fix later. Capture LCP with its phases and CLS with its sources in one pass:

javascript
// Text-LCP render delay and the shifts that follow the swap, in one observer pair.
new PerformanceObserver((list) => {
  const e = list.getEntries().at(-1);
  console.log('LCP', Math.round(e.startTime), e.element?.tagName,
    'renderDelay≈', Math.round(e.startTime - (e.loadTime || e.startTime)));
}).observe({ type: 'largest-contentful-paint', buffered: true });

new PerformanceObserver((list) => {
  for (const s of list.getEntries()) {
    if (s.hadRecentInput) continue;              // ignore user-initiated movement
    console.log('shift', s.value.toFixed(4),
      s.sources?.map((x) => x.node?.nodeName).join(','));
  }
}).observe({ type: 'layout-shift', buffered: true });
// trade-off: console logging is a debugging aid, not instrumentation. In
// production, batch these into a single beacon on visibilitychange instead.

If the LCP element is a text node and its render delay is large while its resource load time is zero, the font is the cause: nothing was loading for that element, the browser was simply refusing to paint it.

3. Shorten Discovery Before Optimizing Bytes

The font request usually starts late for a structural reason, not a bandwidth one. The browser must download the HTML, then the CSS, then parse the @font-face rule, then match a rendered element to that face before it will fetch anything. That is three round trips of latency before a single font byte moves.

Font discovery: default chain versus preload The default path serialises HTML, stylesheet, font-face matching and the font fetch, while a preload link starts the font download in parallel with the stylesheet. Where the font request starts Default HTML stylesheet @font-face match font fetch starts after 3 hops Preload HTML stylesheet font fetch parallel with the CSS — one hop, not three

Preloading collapses that chain, but only for faces you are certain will render. Each preload competes for bandwidth with the HTML-blocking CSS, so preloading five faces to save one is a net loss. The rule that holds up in production: preload exactly the faces used by above-the-fold text — usually one body weight and one heading weight — and let everything else be discovered normally.

html
<!-- In <head>, BEFORE the stylesheet that declares the face. -->
<link rel="preload" href="/fonts/inter-var-latin.woff2" as="font"
      type="font/woff2" crossorigin>
<!-- trade-off: a preload is a promise that this exact file WILL be used. If the
     face is swapped, resubsetted, or only used below the fold, the preload is
     wasted bandwidth on the critical path and Chrome logs an unused-preload
     warning. Re-check preloads whenever the font build changes. -->

The crossorigin attribute is mandatory even for same-origin fonts: fonts are fetched in anonymous CORS mode, so a preload without it fetches a second, separate copy rather than priming the one the CSS will use. This class of mistake — a preload that does not match the eventual request — is common enough to have its own walkthrough in fixing unused-preload warnings.

4. Choose a Display Strategy Per Content Role

font-display is not a site-wide setting. It is a per-face decision about what should happen during the window where the font is not yet available, and the right answer depends on whether the text is the LCP candidate and whether the face is decorative.

ValueBlock periodSwap periodBest forMetric effect
auto / block~3sinfiniteNothing on a content pageText LCP waits for the font
swap~0msinfiniteBody copy, headingsFast text paint; shift unless metrics match
fallback~100ms~3sLong-form article bodySmall block risk, bounded shift window
optional~100msnoneFonts you can live withoutNo shift after first paint; font may be skipped
swap + overrides~0msinfiniteBrand-critical headingsFast paint AND no shift

The last row is the target state for most sites: swap gives you the fast first paint, and metric overrides remove the reason the swap moves anything. The trade-off between swap and optional — which one wins depends on whether you can tune the fallback — is worked through in font-display: swap vs optional for LCP.

5. Make the Fallback Occupy the Same Box

The swap only shifts layout because two fonts disagree about geometry. size-adjust, ascent-override and descent-override let you declare a local fallback face whose metrics match the web font, so the fallback renders in exactly the space the real font will later fill.

css
/* The real face: fast paint, no invisible-text period. */
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var-latin.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
  unicode-range: U+0000-00FF, U+2000-206F;   /* latin + punctuation only */
}

/* The metric-matched fallback the browser paints during the swap period. */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107.4%;        /* per-glyph advance width, measured not guessed */
  ascent-override: 90.2%;
  descent-override: 22.5%;
  line-gap-override: 0%;
}

body { font-family: "Inter", "Inter Fallback", sans-serif; }
/* trade-off: these percentages are specific to ONE fallback on ONE platform.
   Arial and Roboto differ, so a single override set is a compromise across
   your device mix — measure against your two most common fallbacks and accept
   a sub-pixel residual on the rest. */

Measured overrides typically take the swap contribution to CLS from 0.05–0.15 down to under 0.005, which is inside measurement noise. The step-by-step measurement procedure — including how to derive each percentage rather than copying someone else's — is in fixing CLS from web font swap.

6. Cut Bytes with Subsetting and unicode-range

A full variable font with every script is often 200–400KB; the Latin subset of the same face is 15–30KB. Subsetting is the largest single byte saving available in the font pipeline, and it is safe as long as the unicode-range descriptor tells the browser exactly which codepoints each file covers.

bash
# Latin + punctuation subset of a variable font, keeping the weight axis intact.
pyftsubset inter-var.ttf \
  --output-file=inter-var-latin.woff2 --flavor=woff2 \
  --layout-features='kern,liga,calt' \
  --unicodes="U+0000-00FF,U+2000-206F,U+2212" \
  --no-hinting --desubroutinize
# trade-off: an aggressive subset breaks the moment content introduces a
# codepoint you dropped (a user name, a currency symbol, a quoted phrase in
# another script). Ship a second range-scoped file for those instead of
# widening the primary subset for everyone.

Because unicode-range is evaluated per file, a page that never renders Cyrillic never downloads the Cyrillic file at all — the byte saving is conditional and automatic. Serve every one of these files with a long-lived immutable cache policy as described in HTTP Cache-Control headers explained; font files are content-hashed and never change, which makes them the ideal immutable candidate.

Deconstructing the Text-Paint Timeline

Break the delay into phases and you can tell which fix applies, instead of trying all of them.

  • Discovery delay — HTML byte zero to font request start. Owned by the request chain: preload, Early Hints, and self-hosting shorten it. Target under 300ms on a p75 mobile profile.
  • Transfer time — request start to response complete. Owned by bytes and connection reuse: subsetting and WOFF2 shorten it. A 20KB Latin subset transfers in well under 200ms on a warm connection.
  • Block period — the window where text is deliberately invisible. Owned entirely by font-display. This is the phase that most directly inflates text LCP render delay, and swap reduces it to approximately zero.
  • Swap moment — the reflow when the face applies. Owned by metric overrides. It contributes nothing to LCP but everything to font-driven CLS.

The clean separation matters: if your baseline shows a short discovery delay and a large render delay, no amount of preloading will help — the block period is the problem and the fix is font-display. If discovery is late but the block period is already zero, the reverse is true.

Advanced Diagnostics and Edge Cases

Icon fonts are the worst case of every category. They are usually font-display: block by inheritance of the default, they render into fixed-size boxes so the fallback shows a blank or a .notdef glyph, and they are frequently loaded from a third party. Replacing an icon font with inline SVG removes a render-blocking request, a shift source, and a connection at once. Where migration is not feasible, font-display: block is genuinely the right choice for icons — a blank icon box for 100ms beats a flash of unrelated glyphs.

Variable fonts change the arithmetic. A single variable file replacing four static weights usually wins on total bytes and always wins on request count, but the single file is larger, so the first weight paints later than a static equivalent would. On pages that use exactly one weight above the fold, a static subset for that weight plus a lazily-loaded variable file for the rest paints fastest.

Framework hydration can re-trigger the swap. In an SSR framework, a component that re-renders text with a different class after hydration can produce a second remeasure well after load. Since layout shifts within 500ms of an interaction are excluded but hydration is not an interaction, that shift counts in full. Detect it by watching for layout-shift entries with a timestamp after the hydration mark, and fix it by ensuring the server and client render identical typography classes.

document.fonts.ready is not a paint signal. It resolves when the font loading process finishes, which on a page using optional may be after the browser has already decided not to use the face. Treat it as a diagnostic hook, never as a gate for showing content — gating content on it reintroduces invisible text under a different name.

Preload plus optional is a deliberate combination. optional gives the browser a ~100ms window to have the font available; without a preload, the font almost never arrives in time on a cold cache, so first-time visitors always get the fallback. Preloading it makes the window winnable while keeping the guarantee that a late font is never swapped in.

Validation and Budgeting in CI

Prove the fix with two assertions — one for each metric the font can damage — and one audit that stops a regression at the source.

json
{
  "ci": {
    "collect": { "numberOfRuns": 3 },
    "assert": {
      "assertions": {
        "font-display": ["error", { "minScore": 1 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "uses-text-compression": ["off"]
      }
    }
  }
}

The font-display audit fails any face left on the default block behaviour, which catches the most common regression: a new @font-face added by a design update without a display descriptor. The CLS assertion is set tighter than the 0.1 Core Web Vitals boundary on purpose — typography should not consume half the budget, and a 0.05 ceiling leaves room for the shifts you cannot control.

For field confirmation, segment your RUM data by whether the font was served from cache. First-time visitors experience the entire discovery-plus-transfer cost; repeat visitors experience none of it. A p75 that looks fine in aggregate can hide a first-visit LCP well over the budget when the cache-hit population is large.

FAQ

Should I preload every font the page uses?

No. A preload moves a request to the front of the queue, and the queue is finite — preloading five faces delays the stylesheet and the LCP image to save a font that was not on the critical path. Preload only the faces that render above the fold, which is typically one body weight and one heading weight. Everything else should be discovered normally so it competes for bandwidth after the critical resources are in flight.

Why did my CLS get worse after I switched to font-display: swap?

Because swap did not create the shift, it revealed it. With the default block behaviour the browser hid the text until the font arrived, so there was never a fallback frame to shift away from — at the cost of a much later text paint. Once you paint the fallback first, any geometry difference between the two faces becomes a visible reflow. The fix is not to revert but to add size-adjust and the ascent/descent overrides so the fallback occupies the same box.

Is self-hosting always faster than a font CDN?

For the first render, almost always: self-hosting removes a DNS lookup, a TLS handshake and (for the common Google Fonts pattern) a CSS request that must complete before the font file is even known. Cross-site caching no longer offsets this, because browsers partition their caches per top-level site, so a visitor never reuses another site's copy of the font. A font CDN still wins on convenience and on serving per-browser formats automatically, which matters if your build cannot subset.

How do I know which fallback to tune the metric overrides against?

Tune against the fallback your users actually get, which is decided by the last entry in your font-family stack and the platform. Read the device breakdown in your field data: a mostly-Android audience falls back to Roboto, a mostly-iOS one to a system face, and a desktop-heavy one to Arial or Helvetica. If two fallbacks are close in share, tune to the larger one and verify that the residual shift for the other stays under 0.01.