Speed Lab: font swap and fallback metrics

Montserrat loaded with swap, block and optional, with a preload, and with a fallback font matched to its size and line metrics: layout shift and first paint.

IntermediateHow-to9 min readUpdated October 30, 2026

In this guide
  1. The question
  2. The page and the font
    1. The six variants
  3. Results
    1. When the font arrives
  4. The swap shift
  5. The matched fallback
  6. Optional and block
  7. The preload
    1. The same test on a shaped network
  8. A trap: the fallback that did not load
  9. What this test does not show
  10. What to do
  11. Run it yourself
  12. Mistakes to avoid
  13. Check your own page
  14. Sources

The question

A web font arrives after the page has started to draw. With font-display: swap, the browser shows the text in a fallback font first and swaps when the real font is ready. If the two fonts differ in width, lines wrap differently, paragraphs change height, and everything below them moves. That is a layout shift. The guide to fixing font layout shift describes the cures. This test measures them: how big is the shift, and what do the usual fixes do to it and to the first paint?

The page and the font

The font is Montserrat Regular, the Latin subset as a WOFF2 file of 14,940 bytes, from the @fontsource/montserrat package. Montserrat is wide, which makes the effect easy to see. It is among the families the 2025 Web Almanac lists as most requested from Google Fonts. The page holds a header, a heading, five paragraphs of text at 20 px with a 30 px line height, a small box and a last line. The first four paragraphs sit inside the first screen of the 412 by 823 test phone. The style is inline, so the browser finds the font rule as soon as it reads the page.

The fallback font is Liberation Sans, which is what this Linux machine draws when a page asks for Arial. I named it directly, for a reason explained below. The setup is described in how the Speed Lab tests are run. Each variant was measured nine times, alternating between variants, with Lighthouse's mobile settings and a server on the same machine.

The six variants

/* a. no web font: the fallback only */
body { font-family: "Liberation Sans", sans-serif; }

/* b. swap */
@font-face { font-family: "Test Sans"; src: url(webfont.woff2) format("woff2"); font-display: swap; }
body { font-family: "Test Sans", "Liberation Sans", sans-serif; }

/* c. swap, with a fallback font matched to the web font */
@font-face { font-family: "Test Sans Fallback"; src: local("Liberation Sans");
  size-adjust: 113.13%; ascent-override: 85.57%; descent-override: 22.19%; line-gap-override: 0%; }
body { font-family: "Test Sans", "Test Sans Fallback", "Liberation Sans", sans-serif; }

/* d. optional: as b, with font-display: optional */
/* e. swap, with <link rel="preload" href="webfont.woff2" as="font" type="font/woff2" crossorigin> in the head */
/* f. block: as b, with font-display: block */

Results

First Contentful Paint and Largest Contentful Paint were the same moment in every variant except block, so they share a column unless they differ.

VariantFirst paint, median (lowest to highest)Largest Contentful PaintCumulative Layout Shift
a. Fallback only651 ms (639 to 664)651 ms0
b. swap648 ms (641 to 669)648 ms0.042
c. swap with matched fallback652 ms (638 to 660)652 ms0.021
d. optional662 ms (649 to 691)662 ms0
e. swap with preload756 ms (737 to 769)756 ms0.042
f. block651 ms (642 to 672)1,320 ms (1,304 to 1,338)0.042

The layout shift scores did not vary at all between runs, because the text and its wrapping are the same every time. Total Blocking Time was 0 throughout.

When the font arrives

The font file was requested at about 640 ms, right after the page was read, and finished at about 1,290 ms. The first paint is at about 650 ms. So in every variant the real font arrives about 640 ms after the page first appears, and the swap happens then.

The swap shift

In the fallback-only page the paragraphs are 180, 150, 150, 150 and 150 px tall. When Montserrat loads, the first paragraph grows from 180 to 210 px, one more line, and the second from 150 to 180 px. Everything below moves down 60 px. On a screen 823 px tall that is a distance fraction of 0.073, and the elements that move cover more than half of the screen, which gives the score of 0.042. A score below 0.1 is good, so this swap passes on its own. A page with more text, a larger size, or a second shifting element would not.

The matched fallback

The idea, from Chrome's guide to improved font fallbacks, is to make the fallback font behave like the web font: scale it so its letters are as wide, and set its ascent, descent and line gap to the web font's. The numbers come from the font files. I measured the width of the page's own text in each font, which fits the page better than a font-wide average, and read the vertical metrics from the font. The script is below. For Montserrat against Liberation Sans it gave 113.13% for size-adjust, 85.57% for the ascent, 22.19% for the descent and 0% for the line gap.

The result was half the shift: 0.021. The fallback now has the web font's overall size, and everything below the first paragraph moved 30 px instead of 60. The first paragraph still gained one line when the real font arrived. Matching the average width of a text does not make every line break fall in the same place. The matched fallback reduces the shift, and on this page it did not remove it.

Optional and block

font-display: optional painted at 662 ms with no shift: the score was 0. But the page was drawn in the fallback font and stayed that way. In a separate check in Chromium, the font file had downloaded, yet the text kept the fallback, and the main part of the page was 1,013 px tall, the same as the fallback-only page, instead of 1,073 px. MDN describes optional as an extremely small block period and no swap period. The browser uses the web font only if it is ready almost at once, and never swaps it in afterwards, so on a slow first visit the visitor does not see it.

font-display: block hid the main text until the font arrived. The first paint was still at 651 ms, because the header uses the fallback, but the Largest Contentful Paint was at 1,320 ms, 669 ms later than with the fallback only. The page shifted when the text appeared, scoring 0.042 as with swap. Lighthouse's "Font display" insight flagged this variant and no other, estimating 645 ms of wasted time for the font.

The preload

A preload starts the font request earlier. Here it moved the request from 637 ms to 610 ms, and the file finished about 25 ms sooner, at 1,261 ms. The gain was small because the font rule was inline in the page, so the browser found the font almost as early without help. A page whose font rule sits in a separate style sheet would gain more.

The preload did not stop the swap, since the file still arrived long after the first paint, and the score stayed at 0.042. More surprising, the first paint came at 756 ms instead of 648 ms, 108 ms later, and the ranges do not overlap. I did not find the reason. A preload is not free, so measure the first paint on your own page before and after you add one. See resource hints.

The same test on a shaped network

The preload result was odd enough to check. I ran the six variants again on the shaped network of the test of many small files, a round trip of 150 ms and a shared link, five runs each.

VariantFirst paintLargest Contentful PaintCumulative Layout ShiftFont file finished
a. Fallback only396 ms (388 to 416)396 ms0
b. swap417 ms (412 to 438)417 ms0.042712 ms
c. swap with matched fallback436 ms (387 to 522)436 ms0.021726 ms
d. optional413 ms (384 to 428)413 ms0688 ms
e. swap with preload520 ms (507 to 562)520 ms0.042668 ms
f. block400 ms (398 to 421)738 ms (730 to 751)0.042699 ms

The layout shift scores are the same as before. The preload made the first paint later again, 520 ms against 417 ms for swap, with ranges that do not overlap, and the font file did arrive sooner (668 ms against 712 ms). So the later paint is not an artifact of the usual throttling, and I still cannot explain it. block held the main text back by 338 ms here, where the usual throttling found 669 ms.

A trap: the fallback that did not load

In an early trial I wrote the matched fallback with src: local("Arial"), the name usually given, and the shift came out as large as without it. A line of test text had the same width in that face as in a font that does not exist, so the face had not loaded, although a plain font-family: Arial is drawn with Liberation Sans through the system's font aliases. MDN explains it: a local() name is matched against the PostScript name or the full name of an installed font, and platform substitutions must not be used. Naming the installed font fixed it.

You can check in the browser console of your page. If the face did not load, the promise is rejected:

document.fonts.load('40px "Test Sans Fallback"').then(
  (faces) => console.log('loaded', faces.length, 'face'),
  (error) => console.log('did not load:', error.name));

On my machine it printed did not load: NetworkError for local("Arial") and loaded 1 face for local("Liberation Sans"). Visitors on Windows and macOS have a real Arial, which local("Arial") matches, so check each kind of device you care about.

What this test does not show

What to do

  1. Choose `font-display` on purpose. swap or optional for body text. See web fonts and speed.
  2. Match the fallback font to the web font with size-adjust and the three overrides, and check that the face loads.
  3. Look at the result, not only the numbers. The page should not jump when the font arrives.
  4. Test a preload and keep it only if the first paint and the shift improve.
  5. Reduce what the swap can move: fewer font files, a subset, and layouts that do not depend on exact line breaks.

Run it yourself

Install the font reader with npm install opentype.js, get the web font as a WOFF or TTF file (the reader cannot open WOFF2), and put a sample of your page's text in a file. Then save this as font-metrics.mjs and run it with the font, the fallback font file, the text file and the name of the installed fallback font.

// font-metrics.mjs: node font-metrics.mjs webfont.woff fallback.ttf text.txt "Local Font Name"
// Prints a fallback @font-face rule whose size and line metrics match the web font.
import { readFileSync } from 'node:fs';
import opentype from 'opentype.js';

const [webPath, fallbackPath, textPath, localName] = process.argv.slice(2);
const load = (path) => { const b = readFileSync(path); return opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength)); };
const web = load(webPath);
const fallback = load(fallbackPath);
const text = readFileSync(textPath, 'utf8');

// Width of the text in ems, with no kerning, for each font.
const width = (font) => font.getAdvanceWidth(text, 1000, { kerning: false }) / 1000;
const sizeAdjust = width(web) / width(fallback);
const { ascender, descender, lineGap } = web.tables.hhea;
const pct = (units) => `${((units / web.unitsPerEm / sizeAdjust) * 100).toFixed(2)}%`;

console.log(`@font-face {
  font-family: "Fallback for the web font";
  src: local("${localName}");
  size-adjust: ${(sizeAdjust * 100).toFixed(2)}%;
  ascent-override: ${pct(ascender)};
  descent-override: ${pct(Math.abs(descender))};
  line-gap-override: ${pct(lineGap || 0)};
}`);

For the Montserrat WOFF, Liberation Sans and the page's text it printed the four numbers used above. It reads a font's horizontal header values, which some fonts do not use for line spacing, so confirm the result in the browser.

Mistakes to avoid

Check your own page

Run the free audit and open the layout shift culprits. If a web font is named, check its font-display value and set up a matched fallback, then run the audit again.

Sources

Start here

Free website audit

Live Google data for any public page: mobile and desktop.

Mobile + desktop · Lighthouse lab test · Real-user Chrome data when Google has it

Rather have this done for you? Speed optimization · Book a call

Related guides

All guides →