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.
In this guide
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.
| Variant | First paint, median (lowest to highest) | Largest Contentful Paint | Cumulative Layout Shift |
|---|---|---|---|
| a. Fallback only | 651 ms (639 to 664) | 651 ms | 0 |
b. swap | 648 ms (641 to 669) | 648 ms | 0.042 |
c. swap with matched fallback | 652 ms (638 to 660) | 652 ms | 0.021 |
d. optional | 662 ms (649 to 691) | 662 ms | 0 |
e. swap with preload | 756 ms (737 to 769) | 756 ms | 0.042 |
f. block | 651 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.
| Variant | First paint | Largest Contentful Paint | Cumulative Layout Shift | Font file finished |
|---|---|---|---|---|
| a. Fallback only | 396 ms (388 to 416) | 396 ms | 0 | |
b. swap | 417 ms (412 to 438) | 417 ms | 0.042 | 712 ms |
c. swap with matched fallback | 436 ms (387 to 522) | 436 ms | 0.021 | 726 ms |
d. optional | 413 ms (384 to 428) | 413 ms | 0 | 688 ms |
e. swap with preload | 520 ms (507 to 562) | 520 ms | 0.042 | 668 ms |
f. block | 400 ms (398 to 421) | 738 ms (730 to 751) | 0.042 | 699 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
- One font and one text. The size of the shift depends on how different the fonts are and how much text is in view.
- A first visit. On a later visit the font is in the browser's cache, and the swap happens at once or not at all.
- A fallback made on Linux. Windows and macOS draw Arial, whose metrics may differ slightly from Liberation Sans, so the numbers for a real Arial would differ a little.
- The layout shift score only for the load. Lighthouse measured the shift while the page loaded, as the method page explains.
- The reason for the later first paint with a preload. I measured it and cannot explain it.
What to do
- Choose `font-display` on purpose.
swaporoptionalfor body text. See web fonts and speed. - Match the fallback font to the web font with
size-adjustand the three overrides, and check that the face loads. - Look at the result, not only the numbers. The page should not jump when the font arrives.
- Test a preload and keep it only if the first paint and the shift improve.
- 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
- Trusting a `local()` name without checking that the face loads.
- Using `block` for body text. It hides the main content.
- Expecting `optional` to show your font on a first visit to a slow connection.
- Adding a preload and not measuring the first paint.
- Stopping at a lower shift score without checking how the page looks.
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
- Improved font fallbacks on Chrome for Developers: the equations that turn font metrics into
size-adjustand the three overrides. - size-adjust, font-display and src on MDN: the block and swap periods, and how a
local()name is matched. - Fonts chapter, Web Almanac 2025: how often each family is requested.
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