DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on Originally published at devya.dev

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

Headline: 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 I had to compute the override descriptors myself.

I run a bilingual site with English and Arabic routes. The English routes were visually stable. The Arabic routes jumped on every cold load, and the jump moved the whole right-to-left column, not one line. These are my notes on what was actually happening and what fixed it.

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.

What actually causes font-related layout shift?

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 for me, and what doesn't it?

next/font — the font loader built into Next.js since version 13.2, imported as next/font/google or next/font/local — 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 <link rel="preload"> for the font files a route uses. It sets font-display: swap. And with adjustFontFallback, which defaults to 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: Cairo, sans-serif by family name, which drops the generated fallback out of the chain entirely. That last one is the mistake I actually shipped: my Tailwind theme pointed --font-sans at the family name instead of the CSS variable the loader returns.

// app/fonts.ts
import { Inter, Cairo } from 'next/font/google';

export const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-sans',
  adjustFontFallback: true, // default — emits the size-adjusted fallback @font-face
});

export const cairo = Cairo({
  subsets: ['arabic', 'latin'],
  display: 'swap',
  variable: '--font-sans-ar',
});

// Use the variable, never the family name:
// font-family: var(--font-sans), system-ui, sans-serif;
Enter fullscreen mode Exit fullscreen mode

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.

// scripts/font-metrics.ts — run once, paste the output into CSS
import fontkit from 'fontkit';

const web = fontkit.openSync('./public/fonts/Cairo-Variable.ttf');
// macOS ships Geeza Pro inside a .ttc collection: pass the PostScript name
const fallback = fontkit.openSync('/System/Library/Fonts/Supplemental/GeezaPro.ttc', 'GeezaPro');

const avg = (f: any) => f['OS/2'].xAvgCharWidth / f.unitsPerEm;
const sizeAdjust = avg(web) / avg(fallback);
const pct = (n: number) => `${(n * 100).toFixed(2)}%`;

console.log({
  sizeAdjust: pct(sizeAdjust),
  ascentOverride: pct(web.ascent / (web.unitsPerEm * sizeAdjust)),
  descentOverride: pct(Math.abs(web.descent) / (web.unitsPerEm * sizeAdjust)),
  lineGapOverride: pct(web.lineGap / (web.unitsPerEm * sizeAdjust)),
});
Enter fullscreen mode Exit fullscreen mode

When should I 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.

font-display Block period Swap period Layout shift risk Use when
swap ~100 ms Infinite High unless fallback metrics are matched Body text with a size-adjusted fallback
optional ~100 ms Zero None from swapping Display faces, CLS-sensitive routes
fallback ~100 ms ~3 s Medium — swap only inside the window Compromise between the two above
block ~3 s Infinite Low shift, but invisible text hurts LCP Icon fonts where a wrong glyph is worse than no glyph

I kept swap for body text once the fallback metrics matched, and moved the display face to optional.

Why did my Arabic text still shift after all that?

Arabic kept 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 so it only claims Arabic code points, with overrides computed against the real system font instead of Arial.

@font-face {
  font-family: 'Cairo Fallback AR';
  src: local('Geeza Pro'), local('Noto Naskh Arabic'), local('Tahoma');
  /* numbers below are illustrative — generate your own with the script above */
  size-adjust: 96.40%;
  ascent-override: 100.70%;
  descent-override: 34.60%;
  line-gap-override: 0%;
  unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF, U+FB50-FDFF, U+FE70-FEFF;
}

[dir='rtl'] body {
  font-family: var(--font-sans-ar), 'Cairo Fallback AR', sans-serif;
}
Enter fullscreen mode Exit fullscreen mode

Two details cost me time here. fontkit.openSync() on a macOS .ttc collection returns the collection rather than a face, so the metrics read back as undefined until you pass the PostScript name 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 for me: 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 I 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 reports every shift together with the nodes responsible:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as any[]) {
    if (entry.hadRecentInput) continue;
    console.log(entry.value, entry.sources?.map((s: any) => s.node));
  }
}).observe({ type: 'layout-shift', buffered: true });
Enter fullscreen mode Exit fullscreen mode

entry.sources[0].node points at the element that actually moved, which is what turns CLS is too high into the paragraph under the hero rewraps. The Rendering panel in Chrome DevTools has a Layout Shift Regions toggle that paints the moving areas as they happen. 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.

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 I 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 I get the ascent, descent, and xAvgCharWidth numbers for a font?

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.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)