Skip to main content
Why Fonts Cause Layout Shift: Field Notes on next/font, size-adjust, and Arabic Fallback Metrics

Why Fonts Cause Layout Shift: Field Notes on next/font, size-adjust, and Arabic Fallback Metrics

September 13, 2026
Frontend Engineering
9 min read

Font-driven layout shift is a metrics mismatch, not a download-speed problem. The fallback font the browser paints first occupies a different box than the webfont that replaces it. next/font removes that mismatch automatically for Latin text by generating a size-adjusted fallback @font-face, and it does not remove it for Arabic, where the override descriptors have to be written by hand.

Key takeaways

Font layout shift is a metrics mismatch between the fallback font and the webfont, not a slow-download problem — a faster CDN does not remove it.

next/font self-hosts font files, preloads them per route, and with adjustFontFallback generates a fallback @font-face carrying size-adjust, ascent-override, descent-override, and line-gap-override.

That generated fallback is derived from a Latin system font, so Arabic, Cyrillic, and CJK text still shifts and needs a hand-written fallback @font-face scoped with unicode-range.

font-display: optional removes swap-driven shift entirely: it has a block period of roughly 100 ms and a swap period of zero.

Reproduce a cold load with cache disabled and throttling on — a warm font cache hides this bug on every local test while real users keep hitting it.

Font layout shift happens when the fallback font the browser paints during the download occupies a different amount of space than the webfont that replaces it. Cumulative Layout Shift (CLS) — the Core Web Vitals metric that sums unexpected movement of visible elements, where 0.1 or less is the good threshold — records that swap as a shift whenever it moves surrounding content. Download speed does not help: on a cold cache the browser still paints at least one frame with the fallback.

Two numbers decide how big the jump is. The first is average character width: if the fallback is wider, text wraps onto more lines and every block below it moves down. The second is the ascent-to-descent ratio, which sets the height of each line box. A fallback that is only four percent wider than the webfont can still push a paragraph onto an extra line and move a hero image.

font-display: swap, the value next/font uses by default, paints fallback text immediately and swaps in the webfont whenever it arrives. Swap guarantees the text is readable, and it guarantees a shift unless the two fonts have matching metrics.

What does next/font actually do, and what doesn't it?

next/font is the font loader built into Next.js since version 13.2, imported as next/font/google or next/font/local. It does four things at build time: it downloads and self-hosts the font files from your own origin so there is no runtime request to fonts.gstatic.com, it emits a preload link for the font files a route uses, it sets font-display: swap, and with adjustFontFallback (default true) it generates a second @font-face for a local fallback carrying size-adjust, ascent-override, descent-override, and line-gap-override derived from the webfont's own metrics.

Three things next/font does not do. It does not pick a sensible fallback for a non-Latin script, because the generated fallback is based on Arial or Times New Roman. It does not preload a font imported in a component the route never renders. And it does not protect you when your CSS re-declares font-family by family name, which drops the generated fallback out of the chain entirely. That last one is the failure we actually shipped: a Tailwind theme pointing --font-sans at the family name instead of the CSS variable the loader returns.

In practice the rule we now enforce in review is simple: declare the font once in app/fonts.ts with an explicit variable, and reference only that variable in CSS — font-family: var(--font-sans), system-ui, sans-serif.

How do size-adjust and the override descriptors remove the shift?

The four descriptors rescale the fallback font so it occupies the same box as the webfont before the swap happens. size-adjust is a @font-face descriptor that multiplies every glyph's advance width and the effective em size by a percentage. ascent-override, descent-override, and line-gap-override replace the font's vertical metrics so line box height stops depending on which font is currently active. Chrome and Firefox have supported all four since 2021, and Safari added them in version 17. Browsers that do not understand the descriptors ignore them, so this is progressive enhancement rather than a compatibility risk.

The ratio Next.js uses — and fontaine, the framework-agnostic plugin that does the same job for Vite and Nuxt — is straightforward: normalise each font's average character width by its units-per-em, divide the webfont's value by the fallback's, then express the webfont's vertical metrics relative to that adjusted em. A short fontkit script reading the OS/2 and hhea tables prints the four percentages once, and the output goes straight into CSS.

When should we use font-display: optional instead of swap?

Use font-display: optional when CLS on a route matters more than showing the brand font to first-time visitors. optional gives the font a block period of roughly 100 ms and a swap period of zero: if the file is not ready in time, the browser keeps the fallback for that entire page view and uses the webfont on the next navigation, once it sits in the HTTP cache. No swap means no swap-driven shift, whatever the metrics mismatch.

swap has a block period of about 100 ms and an infinite swap period, so its shift risk is high unless fallback metrics are matched; it suits body text. optional has a zero swap period and no swap-driven shift; it suits display faces and CLS-sensitive routes. fallback sits between them with a swap window of about three seconds. block waits roughly three seconds before painting any text, which trades layout shift for invisible text and a worse Largest Contentful Paint.

Why does Arabic text still shift after all that?

Arabic keeps shifting because adjustFontFallback generates its override against a Latin system font, while the font that actually renders Arabic glyphs is a different file with different metrics: Geeza Pro on macOS and iOS, Noto Naskh Arabic or Noto Sans Arabic on Android, Segoe UI or Tahoma on Windows. Arabic system fallbacks also tend to have taller line boxes than Latin ones, so the mismatch appears as vertical movement down an entire right-to-left column rather than one rewrapped line.

The fix is a hand-written fallback @font-face per script, scoped with unicode-range (U+0600-06FF, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF) so it only claims Arabic code points, with size-adjust and the vertical overrides computed against the real system font instead of Arial.

Two details cost us time here. fontkit.openSync() on a macOS .ttc collection returns the collection rather than a face, so the metrics read back as undefined until the PostScript name is passed as the second argument. And the unicode-range is not decoration: without it, the Arabic fallback also claims Latin code points and English headings inherit an adjustment computed for the wrong script.

Which font files actually get preloaded?

next/font/google preloads only the subsets you declare, and only on routes where the font module is imported. That makes subsets a correctness setting rather than an optimisation — omit it and the build fails with a missing-subsets error unless a default is configured in next.config. It also means importing an Arabic font in the root layout preloads Arabic font files on every English route.

Splitting the font imports per locale segment removed that waste: the shared root layout owns the Latin font, and the right-to-left layout segment imports the Arabic one. For weights that appear on a handful of pages, preload: false keeps the @font-face without spending early bandwidth on it. A variable font is usually the better default anyway — one file covering the whole weight axis beats four static weights, each with its own preload tag.

How do we verify the fix instead of guessing?

Reproduce a cold load first: open DevTools, check Disable cache, and throttle the network. With a warm font cache the file resolves from disk before first paint and the shift disappears, which is precisely why this class of bug survives local testing and only shows up in field data.

Then attribute the shift instead of eyeballing it. The Layout Instability API, observed through a PerformanceObserver with type 'layout-shift' and buffered: true, reports every shift together with the nodes responsible. entry.sources[0].node points at the element that actually moved, which is what turns a CLS number into a named component. The Rendering panel in Chrome DevTools has a Layout Shift Regions toggle that paints the moving areas live.

Lighthouse confirms the fix in the lab. Only field data from CrUX or your own real-user monitoring confirms it on the devices that count toward Core Web Vitals, so we treat the lab run as a checkpoint and the field data as the verdict.

FAQ

Q: Does next/font remove CLS on its own?

A: For Latin text with adjustFontFallback left at its default of true, next/font removes most font-driven layout shift automatically. It does not cover non-Latin scripts, and it stops working the moment your CSS references the font family name directly instead of the CSS variable the loader returns.

Q: Is font-display: optional always better than swap?

A: optional is better for CLS because it never swaps after its roughly 100 ms block period, but first-time visitors may see the fallback font for the entire page view. Use optional for display faces and swap with matched fallback metrics for body text.

Q: Do we still need preconnect to fonts.gstatic.com with next/font/google?

A: No. next/font/google downloads the font files at build time and serves them from your own origin, so there is no runtime connection to Google's CDN to warm up.

Q: Where do the ascent, descent, and xAvgCharWidth numbers come from?

A: Read them from the font binary with fontkit or opentype.js via the hhea and OS/2 tables, or use the published values in @capsizecss/metrics, which ships metrics for common web and system fonts.

Q: Does any of this apply outside Next.js?

A: Yes. fontaine generates the same size-adjusted fallback @font-face for Vite, Nuxt, and webpack projects, and the four descriptors are plain CSS you can write by hand in any stack.