How to Fix CLS Caused by a Web Font Swap
This walkthrough sits under Font Loading & Text Rendering Performance, part of the Core Web Vitals & Measurement discipline: you switched to font-display: swap to stop text from being invisible, LCP improved, and Cumulative Layout Shift went from 0.02 to 0.14. The fallback and the web font disagree about how much space text occupies, and every paragraph re-flows the moment the real face arrives.
The fix is not to revert. It is to make the fallback occupy the same box, using size-adjust, ascent-override and descent-override values you measure rather than copy.
Rapid Diagnosis: Confirm the Font Is the Shift Source
- Attribute the shift. In the Performance panel, expand the Experience track and select the shift record. The "Moved from / Moved to" entries will name text containers — a heading, a paragraph, an article body — rather than an image or an ad slot.
- Correlate the timestamp. Compare the shift time with the font's response-complete time in the Network panel. A shift landing within a frame or two of the font finishing is definitive.
- Test with the font blocked. Add the font's URL to the Network panel's request-blocking list and reload. If CLS drops to near zero and the page renders entirely in the fallback, the swap is your source.
- Measure the delta directly. Render one line of representative text in the fallback and in the web font at the same
font-size, and compare the rendered widths. A difference above about 2% will produce a visible re-wrap on a full paragraph.
Root Cause Analysis
1. Advance-width mismatch. Every glyph in a font has an advance width. If the web font's average advance is 7% wider than the fallback's, a line that fitted in the fallback overflows into a new line under the web font. This is the dominant term and the one size-adjust corrects.
2. Ascent and descent mismatch. The font's ascent and descent metrics determine the height of each line box independently of line-height in many layout modes. Two fonts with identical widths can still change a paragraph's total height, moving everything below it — corrected by ascent-override and descent-override.
3. Line-gap inflation. Some fonts declare a non-zero line gap that the browser adds to every line box. A face with a large lineGap produces taller paragraphs than a fallback with none, and line-gap-override: 0% normalises it.
4. The wrong fallback in the stack. Overrides are tuned for one specific fallback. If your stack ends in a generic sans-serif, the actual face differs per platform, and a tuning that eliminates the shift on Arial can leave it untouched on Roboto. Naming the fallback explicitly is part of the fix.
Step-by-Step Resolution
1. Measure both faces at the same size. Use the browser itself as the measuring instrument so you capture the real rendering, including hinting.
// Paste into the console on the page whose typography you are matching.
function advance(family, text = 'Handgloves 0123456789 the quick brown fox') {
const c = document.createElement('canvas').getContext('2d');
c.font = `100px ${family}`;
return c.measureText(text).width;
}
await document.fonts.load('100px Inter');
const web = advance('Inter'), fb = advance('Arial');
console.log('size-adjust:', ((fb / web) * 100).toFixed(1) + '%');
// trade-off: canvas measurement uses the same shaping engine as layout, which is
// what you want — but it reflects THIS platform only. Run it on the OS your
// field data says most users are on, not on your development machine.
Expected outcome: a size-adjust percentage accurate to a tenth of a percent for the platform you measured on, typically in the 95–110% range.
2. Read the ascent, descent and line gap. These come from the font file rather than from rendering. Any font-inspection tool works; the numbers you need are the values divided by units-per-em, expressed as a percentage.
# Ascent, descent and line gap as percentages of the em square.
python3 - <<'EOF'
from fontTools.ttLib import TTFont
f = TTFont('inter-var-latin.woff2')
upm = f['head'].unitsPerEm
hhea = f['hhea']
print(f"ascent-override: {hhea.ascent / upm * 100:.1f}%")
print(f"descent-override: {abs(hhea.descent) / upm * 100:.1f}%")
print(f"line-gap-override: {hhea.lineGap / upm * 100:.1f}%")
EOF
# trade-off: hhea metrics and OS/2 metrics can disagree, and browsers do not all
# prefer the same table. If your measured line height does not match the
# computed one, re-read the values from OS/2 sTypoAscender/sTypoDescender.
Expected outcome: three percentages that describe the web font's vertical metrics. Apply them to the fallback face so it matches the web font, and divide each by the size-adjust factor you just derived.
3. Declare the adjusted fallback and put it in the stack.
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107.4%;
ascent-override: 90.2%;
descent-override: 22.5%;
line-gap-override: 0%;
}
:root { --font-body: "Inter", "Inter Fallback", Arial, sans-serif; }
body { font-family: var(--font-body); }
/* trade-off: naming Arial explicitly after the adjusted face keeps a sane
result if local("Arial") is unavailable, but that unadjusted path will still
shift. On platforms where Arial is absent, measure against the face that is
actually used and ship a second adjusted fallback. */
Expected outcome: the fallback now renders in the same box as the web font, so the swap changes glyph shapes without changing geometry. Typical residual shift drops from 0.05–0.15 to under 0.005.
4. Verify the two faces overlay. Toggle the web font off in DevTools and screenshot; toggle it on and screenshot again at the same scroll position. Diff the two images — text should occupy identical line boxes with only glyph shapes differing.
Verification
Reload with the Layout Shift Regions overlay enabled (Rendering panel) and watch the moment the font resolves. Before the fix you will see blue flashes over every text block; after it, nothing should highlight.
Quantify with the same observer you used for the baseline, and compare like for like — a cold cache load, the same viewport, the same scroll position:
let cls = 0;
new PerformanceObserver((l) => {
for (const e of l.getEntries()) if (!e.hadRecentInput) cls += e.value;
}).observe({ type: 'layout-shift', buffered: true });
addEventListener('visibilitychange', () => console.log('CLS', cls.toFixed(4)),
{ once: true });
Lock it in with a CI assertion tighter than the 0.1 threshold, so typography cannot silently reclaim the budget:
{ "ci": { "assert": { "assertions": {
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }],
"font-display": ["error", { "minScore": 1 }]
} } } }
In the field, confirm with CLS segmented by first-time versus repeat visitors. Repeat visitors have the font cached and never experience the swap at all, so an aggregate p75 can look healthy while every new visitor sees the shift. If the first-visit segment does not improve, your overrides are tuned against a fallback those users do not get.
FAQ
Can I just copy size-adjust values from a blog post?
Only as a starting point. Published values are specific to one font version, one fallback and one platform's shaping. Font vendors ship metric changes between releases, and your subset may drop glyphs that shifted the average advance. Measuring takes two minutes with the canvas snippet above and gives you a number you can re-derive when the font is updated.
Does font-display: optional remove the need for overrides?
It removes the shift, not the trade-off. With optional the browser will not swap a late font in at all, so there is no reflow — but first-time visitors on slow connections then see the fallback for the entire visit. Overrides plus swap gives you the branded typography and no shift, at the cost of the measurement work. Choose optional when the face is not worth that work.
Why is there still a small shift after applying overrides?
Sub-pixel rounding and glyph-specific differences mean the match is never exact; a residual under 0.005 is expected and is inside measurement noise. A larger remainder usually means the browser is not using the fallback you tuned — check what document.fonts actually resolved, and confirm the adjusted face appears in the computed font-family before the generic keyword.
Related
- Font Loading & Text Rendering Performance — the full pipeline this fix belongs to, including discovery and subsetting.
- font-display: swap vs optional for LCP — decide whether to eliminate the swap instead of matching it.
- Reducing Cumulative Layout Shift — the other shift sources that share the same
0.1budget.