Build

Font loading without layout shift

The text on a page can move twice while it loads: once when the fallback font is replaced by the real one, and once more if the real one is late enough to be swapped in after the reader has started reading. Both movements show up as layout shift, and both are fixable with a handful of CSS declarations and one preload. The rest of this page goes through them in the order they matter.

Where the shift comes from

Every font has its own proportions: average character width, ascender and descender heights, line gap. When the browser draws text in a fallback font first and then swaps to the web font, the same sentence may wrap differently or sit on a different baseline. web.dev names the two ways a font can cause a shift: a fallback swapped for the new font, or invisible text replaced when the font arrives. Either way the reflow is a layout shift, and a large block of body text can produce a large score.

There are two separate problems to solve:

  1. Timing. How long the browser waits for the web font and what it does in the meantime.
  2. Metrics. How different the fallback and the web font are, so that a swap, if it happens, is nearly invisible.

Step 1: choose a font-display behaviour

font-display tells the browser what to show while the font loads. MDN describes the timeline as three periods. In the block period, text using the font is drawn in an invisible fallback. In the swap period it is drawn in a visible fallback, and the web font replaces it when it arrives. After that is the failure period, in which the font is treated as failed. The five values set how long each period lasts.

ValueBlock periodSwap periodEffect
autoUp to the browserUp to the browserBrowser's own strategy
blockShortInfiniteInvisible text at first, then swaps whenever the font arrives
swapExtremely smallInfiniteFallback almost at once, swaps whenever the font arrives
fallbackExtremely smallShortSwaps only if the font arrives soon, otherwise stays on the fallback
optionalExtremely smallNoneUses the font only if it is ready almost immediately, never swaps in later

With optional, a font that is not ready in time is not used for that page view, and the browser may still finish downloading it for later views. That is acceptable for body text on a site whose visitors browse several pages, and less so for a logotype that must always appear in the brand face.

web.dev adds a Chrome detail worth knowing. Since Chrome 83, combining <link rel="preload"> with font-display: optional is the most effective way to avoid layout shift from custom fonts: rendering waits briefly for the preloaded font, currently about 100 ms, so the page paints once, in whichever font is available. That timeout is described as subject to change, so treat it as behaviour to test, not a constant.

A common, reasonable pairing is swap for the body font with a metric-matched fallback (below), and optional for decorative display faces that are not critical.

Step 2: make the fallback match

The @font-face descriptors size-adjust, ascent-override, descent-override and line-gap-override let you define a local fallback face and tune it to the proportions of your web font. When the swap happens, little or nothing moves.

@font-face {
  font-family: "Body";
  src: url("/fonts/body-var.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}

/* Fictional web font, real Arial metrics: see the worked example. */
@font-face {
  font-family: "Body Fallback";
  src: local("Arial");
  size-adjust: 104.2%;
  ascent-override: 90.2%;
  descent-override: 25%;
  line-gap-override: 0%;
}

body {
  font-family: "Body", "Body Fallback", sans-serif;
}

To calculate real values you need the web font's units per em, ascent, descent, line gap and average glyph width, and the same for the fallback. One widely used method:

  • size-adjust is the ratio of the web font's average character width to the fallback's. MDN describes the descriptor as scaling all metrics of the font, including glyph advances and, notably, the overrides.
  • Because the overrides are scaled too, each override is the web font's metric divided by its units per em, then divided by the size-adjust ratio.

The averaging step is a heuristic, not a standard, so a fallback that is a few percent off will still wrap a long paragraph differently. Overlay the two renderings and check by eye.

A worked example

The fictional web font has 1000 units per em, an ascent of 940, a descent of 260, no line gap and an average character width of 460. The fallback metrics come from the Arial file on the machine I measured, read from its head, hhea and OS/2 tables: 2048 units per em, ascent 1854, descent 434, line gap 67 and an average width of 904. The average widths as fractions of the em are 0.460 and 0.4414, so:

  • size-adjust = 0.460 / 0.4414 = 104.2%
  • ascent-override = 0.94 / 1.042 = 90.2%
  • descent-override = 0.26 / 1.042 = 25.0%
  • line-gap-override = 0%

Check it by working forwards. The rendered ascent is 90.2% times 1.042, which is 0.94 of the em again, so at 16 px the fallback's content area is 19.2 px, the same as the web font's (0.94 + 0.26 = 1.2 em). Unadjusted Arial gives 18.4 px at 16 px, so every line would have grown by almost a pixel on swap.

Two cautions. The CSS Fonts specification notes that a font can carry several sources of these metrics, so layout can differ between platforms. And local("Arial") only works where a font with that name is installed. On a system without it, this fallback face is skipped and the next family in the stack is used, with no adjustment.

Step 3: preload the file that matters

A font is discovered late by default: the browser must download the HTML, parse the CSS, and see text that uses the face before it starts the request. A preload tells it earlier.

<link rel="preload" href="/fonts/body-var.woff2" as="font" type="font/woff2" crossorigin>

Three details decide whether this helps or hurts:

  • crossorigin is required even when the font is on your own domain, because fonts are requested in anonymous CORS mode. web.dev warns that a font preloaded without it is fetched twice.
  • Preload only the faces used in the first screen, usually one or two. web.dev notes that preload takes browser resources from other requests, bypasses the browser's unicode-range selection, and should only load a single format.
  • The URL in the preload must match the URL in @font-face exactly.

Step 4: make the files small

Smaller fonts finish before the swap window closes, which removes the shift entirely in many cases.

  • Use WOFF2. web.dev recommends shipping only WOFF2, which it describes as supported everywhere now.
  • Subset. unicode-range lets you split a family into pieces so the browser downloads a file only when the page contains a matching character. Be careful with the range: a subset limited to basic ASCII drops the accented letters French needs, and even Latin-1 is not enough. French text also uses the ligatures œ and Œ (U+0153 and U+0152) and Ÿ (U+0178) from Latin Extended-A, and typographic punctuation from General Punctuation: the curly apostrophe ’ (U+2019), curly quotes “ ” (U+201C and U+201D), the ellipsis (U+2026), the en dash (U+2013), the em dash (U+2014) and the narrow no-break space (U+202F) used before some punctuation marks. A bilingual English and French site should include Basic Latin, the Latin-1 Supplement, those Latin Extended-A characters and the punctuation block, then test with real French copy. A missing glyph falls back to another font and changes the line.
  • Consider a variable font. One file with a weight range can replace several static files. Whether it is smaller depends on how many styles you use, so measure both options rather than assume.
  • Limit the families. Every extra family is another file on the critical path.

Step 5: choose where the files live

Hosting fonts on your own domain removes a connection to a third-party server, lets you set long cache headers, and lets you control the exact subset. The old argument for a shared CDN was that a visitor might already have the font cached from another site. Chrome partitions its HTTP cache by the top-level site, starting in Chrome 86, so that sharing no longer happens there. web.dev adds a caution: in practice the gap between self-hosted and third-party fonts is less clear-cut than on paper, and self-hosting is only likely to win if you also have a CDN and HTTP/2. Try both and compare transfer times including connection setup. If you do self-host, apply the optimisations that font providers do for you, notably subsetting and WOFF2.

What goes wrong in practice

SymptomLikely causeFix
Text jumps once, a moment after first paintswap with a fallback whose metrics differAdd size-adjust and the ascent, descent and line-gap overrides
Text is invisible for a noticeable momentblock, or a late request for the fontUse swap or fallback, and preload the file
The font downloads twicePreload without crossorigin, or a URL that differs from @font-faceAdd crossorigin and copy the URL exactly
A preloaded subset is downloaded but not neededPreload ignores unicode-range, so it fetches the file regardless of the page's textPreload only the subset the first screen uses
Accents or French characters fall back to another fontThe subset omitted themRebuild the subset with the characters you publish
Shift only on the first visitFont is uncached on arrivalTest with the cache disabled; consider optional

Two smaller points complete the picture. First, a font requested through a stylesheet on another origin costs a DNS lookup, a connection and a request before the file even starts, so web.dev's advice for third-party hosts is a preconnect hint, with a second one carrying crossorigin for the font file connection. Second, a font is fetched only when text on the page actually uses it, so an unused @font-face rule costs nothing, while a face applied to text above the fold is on the critical path whether or not you preloaded it.

Measure it: a before-and-after method

Do not judge a font change by eye on a fast connection. Use a repeatable routine.

  1. Open DevTools, disable the cache, and throttle the network to a slow profile.
  2. Record a Performance trace while reloading and look at the Layout shifts track. Hovering a shift highlights the element that caused it, and clicking one shows its score in the Summary tab.
  3. Log shifts in the page itself so you can compare runs. This version follows web.dev's definition of CLS: shifts near a tap or key press are skipped, and shifts group into windows, each starting a new window after a one-second gap or five seconds in total, with the largest window reported.
let cls = 0, windowValue = 0, windowStart = 0, lastTime = 0;
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.hadRecentInput) continue;
    if (e.startTime - lastTime > 1000 || e.startTime - windowStart > 5000) {
      windowValue = 0;
      windowStart = e.startTime;
    }
    windowValue += e.value;
    lastTime = e.startTime;
    cls = Math.max(cls, windowValue);
    console.log("CLS so far", cls.toFixed(4));
  }
}).observe({ type: "layout-shift", buffered: true });

I ran the grouping logic against fake entries: two shifts of 0.06 half a second apart report 0.12, and a later 0.05 and 0.02 in a new window do not exceed it. For field numbers use the web-vitals library, which handles page-lifecycle edge cases this snippet ignores.

  1. Change one thing (the fallback, the preload, or the font-display value) and repeat. Record the number each time.
  2. Repeat once with a warm cache. Cached fonts often hide a problem that every first-time visitor sees.

Field data from real visitors will confirm the result later. The CLS section of Core Web Vitals in plain language explains how that number is scored.

Choosing quickly

SituationSuggested setup
Body text, one variable fontswap, metric-matched fallback, preload
Decorative display face used onceoptional, no preload, or preload if it is the hero
Bilingual siteSubset with Basic Latin, Latin-1 Supplement, U+0152 to U+0153, U+0178 and General Punctuation; test with real French copy
Many weightsVariable font, measured against static subsets

Typeface choice affects all of this too. A pairing with similar proportions needs less correction; the article on pairing a display serif with a geometric sans covers how to match them by x-height and contrast.

Keep this

  • Pick a font-display value on purpose, and know what the first visit looks like.
  • Define a fallback with size-adjust and the ascent, descent and line-gap overrides, calculated from real metrics.
  • Preload one or two WOFF2 files with crossorigin.
  • Subset with the language you really publish, accents included.
  • Measure shifts with the cache off before and after each change.

Questions and answers

Which font-display value should I use?

swap is a safe default for body text because the fallback shows almost at once. optional gives the least layout shift because the font is never swapped in late, at the cost of some first visits showing the fallback. Pick based on how much a brief fallback matters for your design.

Does preloading a font cost anything?

Yes. It takes bandwidth from other early requests, and web.dev notes that preload ignores unicode-range, so preload only the one or two files used above the fold, in a single format.

Why does preload need the crossorigin attribute for a same-origin font?

Fonts are fetched in anonymous CORS mode, and MDN says the preload's mode must match the eventual request even when the font is on your own origin. web.dev warns that a font preloaded without crossorigin is fetched twice.

Is a CDN-hosted font faster than a self-hosted one?

Not reliably either way. The old argument for a shared CDN was a warm cache from another site, but Chrome has partitioned its HTTP cache by site since version 86. web.dev calls the self-hosting benefit less clear-cut and says it depends on having a CDN and HTTP/2, so compare transfer times yourself.

Sources

  1. Best practices for fonts (web.dev) web.dev
  2. font-display (MDN) developer.mozilla.org
  3. size-adjust (MDN) developer.mozilla.org
  4. ascent-override (MDN) developer.mozilla.org
  5. Gaining security and privacy by partitioning the cache (Chrome for Developers) developer.chrome.com