Preventing CLS from Web Font Loading
When a custom web font finishes loading 200 ms to 2 s after first paint and the browser re-renders text from a fallback face to the web face, every line whose glyph metrics changed reflows — a classic font-swap layout shift that quietly pushes a content-heavy page out of the Good band for Cumulative Layout Shift. This page solves one narrow, recurring scenario: text that paints in a system fallback, then jumps when the web font swaps in because the fallback and the web font do not occupy the same box. The fix is to make the fallback render at the same metrics the web font will render at — so the swap changes glyph shapes but not line heights, line counts, or wrap points — and to load the font fast enough that the swap window is short. This work extends the broader CLS Reduction Strategies collection that this guide sits under.
size-adjust and the ascent/descent/line-gap overrides means the swap changes glyph shapes but never the box dimensions, so nothing below moves.Prerequisites
Before applying the steps below, confirm the following are in place:
- You control the page
<head>and the CSS where@font-facerules are declared, including the document’sfont-familystack onbodyand headings. - You have the web font in WOFF2 and know which weights and styles the page actually renders above the fold — preloading every weight wastes bandwidth and competes with the LCP image and other render-blocking resources.
- You can read
layout-shiftentries from the PerformanceObserver and web-vitals API; the verification step attributes a shift to a text node by readingsources[].node. - You can run Node locally to compute the metric overrides, or you are willing to install Fontaine /
next/fontto generate them automatically. - You have decided your swap policy per font role:
swapfor body copy you must show immediately,optionalfor fonts where an invisible-then-fallback render is acceptable.
The CLS thresholds you are targeting are fixed by the Google spec:
| CLS score (p75) | Band | What a font swap means at this score |
|---|---|---|
| ≤ 0.10 | Good | Fallback metrics match the web font; swap moves nothing |
| ≤ 0.25 | Needs Improvement | One role (often headings) reflows on swap; overrides incomplete |
| > 0.25 | Poor | Unmatched fallback reflows multi-paragraph body copy on swap |
The relevant font-display values trade visibility against shift risk:
font-display |
Block period | Swap behaviour | CLS exposure |
|---|---|---|---|
swap |
~0 ms | Fallback shows immediately, swaps when font loads | High unless fallback is metric-matched |
optional |
~100 ms | Uses font only if cached/fast; else keeps fallback for the page load | None after first paint |
fallback |
~100 ms | Short block, ~3 s swap window, then locks fallback | Medium, bounded swap window |
block |
~3 s | Invisible text up to ~3 s (FOIT), then swaps | High, plus blank-text risk |
How to eliminate font-swap layout shift
1. Self-host the font as WOFF2 and declare an explicit @font-face
Move the font off a third-party host (Google Fonts CSS, a CDN you do not control) and serve the WOFF2 from your own origin. Why: a cross-origin font CSS request adds a connection and a render-blocking round trip you cannot preload accurately, and the swap timing becomes hostage to a third party. Self-hosting lets you set font-display, preload the exact file, and add metric overrides on the same @font-face rule.
/* /fonts/inter.css — self-hosted, one weight, explicit display policy */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap; /* show fallback now, swap when Inter arrives */
src: url('/fonts/inter-regular.woff2') format('woff2');
}
body {
/* The fallback after 'Inter' is what paints before the swap. */
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}
2. Preload the WOFF2 so the swap window is short
Add a <link rel="preload"> for the WOFF2 files that render above the fold, with crossorigin because fonts are always fetched in CORS mode. Why: the sooner the web font arrives, the shorter the window in which a mismatched fallback can shift; a same-origin preload pulls the font into the very first batch of requests instead of waiting for the CSS to be parsed and the font to be discovered. Keep the preload list minimal so it does not contend with the resource that drives First Contentful Paint.
<head>
<!-- Preload only the faces used above the fold. crossorigin is mandatory. -->
<link rel="preload" href="/fonts/inter-regular.woff2" as="font"
type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/inter-semibold.woff2" as="font"
type="font/woff2" crossorigin>
<link rel="stylesheet" href="/fonts/inter.css">
</head>
3. Compute the fallback metric overrides
To make a fallback occupy the same box as the web font, you need four ratios derived from the web font’s head/hhea tables: size-adjust (the per-glyph advance-width ratio that fixes line wrapping) and ascent-override, descent-override, line-gap-override (which fix the line box height). Why: line box height is (ascent + descent + lineGap) / unitsPerEm; if you express those as percentages relative to the fallback font after applying size-adjust, the fallback’s lines occupy the identical height and width, so wrap points and line counts match. Compute the numbers from the font binary with a Node script.
// node compute-overrides.mjs ./fonts/inter-regular.woff2 Arial
// Reads the web font's metrics and a local sample of the fallback's
// average advance width to produce the four @font-face override values.
import { readFileSync } from 'node:fs';
import { Font } from 'fontkit';
const [, , webFontPath, fallbackName] = process.argv;
// Average advance widths (em units) for the chosen fallback, measured once
// from the system font. These are stable per-family constants.
const FALLBACK_AVG_WIDTH = { Arial: 0.5093, 'Times New Roman': 0.4744 };
const font = Font.openSync(webFontPath);
const upm = font.unitsPerEm;
// size-adjust scales the fallback so its average glyph advance equals the
// web font's, which is what makes line wrapping identical.
const webAvgWidth = font.getGlyph(font.glyphForCodePoint(0x78).id) // 'x'
? avgAdvance(font) / upm
: 0.5;
const sizeAdjust = (webAvgWidth / FALLBACK_AVG_WIDTH[fallbackName]) * 100;
// After size-adjust the em is rescaled, so divide the web font's metrics by
// (sizeAdjust/100) to express them relative to the adjusted fallback.
const scale = upm * (sizeAdjust / 100);
const ascent = (font.ascent / scale) * 100;
const descent = (Math.abs(font.descent) / scale) * 100;
const lineGap = (font.lineGap / scale) * 100;
console.log(`size-adjust: ${sizeAdjust.toFixed(2)}%`);
console.log(`ascent-override: ${ascent.toFixed(2)}%`);
console.log(`descent-override: ${descent.toFixed(2)}%`);
console.log(`line-gap-override: ${lineGap.toFixed(2)}%`);
function avgAdvance(f) {
// Sample lowercase ASCII, the bulk of body copy, for a representative mean.
let total = 0, n = 0;
for (let cp = 0x61; cp <= 0x7a; cp++) {
const g = f.glyphForCodePoint(cp);
if (g) { total += g.advanceWidth; n++; }
}
return total / n;
}
4. Declare a metric-matched fallback @font-face
Take the four numbers and write a second @font-face rule that wraps the system font (local('Arial')) and applies the overrides. Name it so it slots into the stack right after the web font. Why: this synthetic face renders before the web font loads, at the web font’s exact metrics, so the eventual swap from Inter Fallback to Inter changes glyph outlines but leaves the line box untouched — the layout-shift score stays at zero because no node moves.
/* Generated from step 3: Arial reshaped to Inter's metrics. */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107.40%;
ascent-override: 90.49%;
descent-override: 22.56%;
line-gap-override: 0.00%;
}
body {
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}
5. Let tooling generate steps 3–4 in a build pipeline
Hand-computing overrides per font is fine once, but a build plugin keeps them correct when fonts change. Fontaine patches your @font-face rules at build time; in Next.js, next/font does the same automatically and injects an adjustFontFallback face. Why: the override math depends on the exact font binary, so coupling it to the build removes the drift where a designer swaps the font file but the stale overrides remain and silently reintroduce the shift.
// next.config.mjs is not needed — next/font computes the fallback face for you.
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
// next/font emits a size-adjust + ascent/descent/line-gap fallback face,
// so the swap is metric-matched with no extra CSS from you.
adjustFontFallback: true,
preload: true,
});
export default function RootLayout({ children }) {
return <html lang="en" className={inter.className}>{children}</html>;
}
// Vite / plain build with Fontaine:
import { FontaineTransform } from 'fontaine';
export default {
plugins: [
FontaineTransform.vite({
fallbacks: ['Arial', 'sans-serif'],
resolvePath: (id) => new URL(`./public${id}`, import.meta.url),
}),
],
};
Verifying it works
Confirm the fix with three independent signals:
- DevTools. Open the Performance panel, throttle to Slow 4G, and record a reload. In the Layout Shifts track there should be no shift band aligned to the font’s network response. Toggle the Rendering → Layout Shift Regions overlay; text paragraphs should never flash blue when the font swaps. The Network panel’s Font filter confirms the WOFF2 was preloaded (Priority
High, initiated by the preload link, not the stylesheet). - Console. Drive the capture below and log instead of beaconing. A metric-matched page logs nothing on swap; a regression logs the text node and its before/after rects, telling you which role still has wrong overrides.
- RUM dashboard. Filter your beacon stream to
metric = cls_fontand chart the p75 ofvalue. After the overrides ship, font-attributed CLS should fall into the Good band (≤ 0.10) and stay there. Segment by device class — a fallback tuned on desktop can still shift on a device whose system fallback differs.
// Attribute layout shifts to text nodes to prove the font swap is clean.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // input-driven shifts are excluded
for (const source of entry.sources || []) {
const node = source.node;
if (!node) continue;
// Text-bearing block elements are the font-swap suspects.
const el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
if (el && /^(P|H1|H2|H3|LI|SPAN|A)$/.test(el.tagName)) {
navigator.sendBeacon('/rum/cls', JSON.stringify({
metric: 'cls_font',
value: entry.value,
tag: el.tagName,
prevRect: source.previousRect.toJSON(),
currRect: source.currentRect.toJSON(),
ts: entry.startTime,
}));
}
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
Edge cases & gotchas
local()name mismatch across platforms.src: local('Arial')resolves to nothing on systems without Arial (most Android, many Linux). List a fewlocal()aliases or pick a fallback that exists on your real traffic; if thelocal()lookup fails, the overrides apply to whatever the browser substitutes, and the match breaks. Validate per device class in field data, not just on your laptop.- Variable fonts and per-weight metrics. Bold and regular can have different advance widths, so a single
size-adjusttuned on regular under-matches bold headings. Generate a separate fallback face per weight you render above the fold, or accept a small heading-only shift and tune to the most-used weight. font-optical-sizingandletter-spacing. If your CSS addsletter-spacingor relies on optical sizing, the measured average advance width no longer predicts the rendered line width. Apply the sameletter-spacingwhen you measure, or the wrap points will still diverge.optionalhides the shift but can hide the font.font-display: optionaleliminates the swap shift by simply not swapping on a slow first load, but then first-time visitors never see your brand font that visit. Useoptionalfor decorative roles and metric-matchedswapfor body copy where the font must show.- Safari layout-shift gaps. Safari does not implement the
layout-shiftentry type, so the observer above captures nothing there. Guard withPerformanceObserver.supportedEntryTypes.includes('layout-shift')and treat Safari font CLS as unmeasured in field data rather than as zero. - Icon fonts. An icon font with no fallback glyphs shows nothing then pops icons in, which shifts inline layout. Reserve icon dimensions with explicit
width/heighton the element, or migrate to inline SVG, rather than relying on font metrics.
FAQ
Is font-display: optional enough on its own to fix CLS?
It removes the swap shift because the browser keeps the fallback for the rest of the page load when the font is not ready in time. But returning visitors with the font cached still swap at paint, and you lose the brand font for slow first loads. For body copy, prefer swap plus metric-matched fallback overrides so the font always shows and never shifts.
Can I get the override numbers without writing a script?
Yes. Fontaine and next/font compute size-adjust and the ascent/descent/line-gap overrides at build time and inject the fallback @font-face for you. The Node script in step 3 is useful when you want the numbers checked into CSS by hand or your stack has no plugin.
Why does my heading still shift when body text is fixed?
A single fallback face matches one weight’s metrics. Headings usually render a heavier weight with different advance widths, so they need their own metric-matched fallback. Generate one fallback face per above-the-fold weight and key each to the right system font.
Related
- CLS Reduction Strategies — the parent guide covering every CLS source and its remediation pattern.
- Reducing CLS from Dynamic Ad Injections — the sibling fix for late ad creatives that shift content alongside font swaps.
- LCP Measurement & Optimization — because a render-blocking font competes with the LCP resource, balance preload priority against the largest paint.