Subset the fonts, match the fallback metrics, stop the layout shift
A page that renders 127 characters should not download 889 glyphs. Subset at build time, metric match the fallback, and the font swap stops moving things.
An ordinary content page renders somewhere between a hundred and two hundred distinct characters. The font file it downloads to do that usually carries several hundred glyphs, in several weights, and it is fetched whole. Then when it arrives the text reflows, the paragraphs change height, and everything below the first screen jumps. Both halves of that are fixable in a build step, and the numbers are large enough to be worth the afternoon.
What actually happens
A web font is one indivisible file per face. There is no range request, no partial decode, no way for the browser to take only the letters it needs. Ask for regular, bold and italic and that is three downloads, each carrying the full character set the designer shipped, including the Cyrillic and Greek blocks you will never print.
The layout shift is a separate mechanism that happens to arrive at the same moment. font-display: swap tells the browser to paint immediately in a fallback and replace it when the real font loads. That is the right choice for readability, and it is why the shift exists: the fallback has its own average character width, its own ascent and descent, its own line height. The same paragraph sets to a different number of lines in the fallback than in the real font, so at swap time every block below it moves. The metric is cumulative layout shift, and font swap is one of the two usual causes, the other being an entrance animation that moves the largest element.
Neither half is solved by making the file smaller alone. A small file swaps sooner, which makes the jump less likely to be seen; a metric matched fallback makes the jump impossible. Do both.
How to see it
Start with the bytes, because they are the easiest number to argue about:
ls -l dist/assets/fonts/*.woff2 | awk '{ t += $5; printf "%8d %s\n", $5, $9 } END { printf "%8d total\n", t }'Then watch the shifts as they happen. Layout shift entries carry the nodes that moved, which turns a score into a list of elements:
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.hadRecentInput) continue;
console.log(e.value.toFixed(4), e.sources.map((s) => s.node?.nodeName).join(' '));
}
}).observe({ type: 'layout-shift', buffered: true });Throttle the network to something slow before you reload, or the font arrives before first paint and you measure nothing. This is the part where the lab conditions matter more than the score: a fast connection hides the bug entirely, and a clean number on your own machine says nothing about a reader on a train.
The fix
Build the character pool from the content rather than from a Unicode range. A range is a guess; the content is the answer:
import subsetFont from 'subset-font';
const pool = new Set();
const add = (s) => { for (const ch of s) pool.add(ch); };
const walk = (v) => {
if (typeof v === 'string') { add(v); add(v.toLocaleUpperCase('tr')); add(v.toUpperCase()); }
else if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
};
walk(content);
for (let c = 0x20; c <= 0x7e; c++) add(String.fromCharCode(c)); // plain ASCII, always
add('ĞğİıŞşÇçÖöÜüéÉ‘’“”…'); // punctuation and the soft hyphen
for (const loc of ['en-US', 'tr-TR']) // anything a date formatter can print
for (let m = 0; m < 12; m++)
add(new Intl.DateTimeFormat(loc, { month: 'long' }).format(new Date(Date.UTC(2026, m, 15))));
const chars = [...pool].join('');
const woff2 = await subsetFont(await fs.readFile(file), chars, { targetFormat: 'woff2' });Two details in there are not decoration. Adding both uppercase forms matters because a headline set in capitals needs glyphs that appear nowhere in the source string, and the Turkish form differs from the invariant one, which is the uppercase i trap reaching into the font pipeline. Adding the date names matters because a page that prints a formatted date can produce characters that exist in no other string on the site.
On one site the pool came out at 127 characters against 889 in the source font:
| face | full woff2 | subset woff2 |
|---|---|---|
| text regular | 63.5 KB | 17.1 KB |
| text bold | 64.7 KB | 17.3 KB |
| text italic | 68.5 KB | 18.8 KB |
| display | 24.5 KB | 6.9 KB |
| masthead, two letters | 74.6 KB | 2.5 KB |
| total | 295.9 KB | 62.6 KB |
Now the fallback. Read the real metrics out of the subset you just made, compare them against the metrics of a font the reader already has, and write the overrides:
import { fromBuffer } from '@capsizecss/unpack';
import georgia from '@capsizecss/metrics/georgia';
const m = await fromBuffer(await subsetFont(src, chars, { targetFormat: 'truetype' }));
const sizeAdjust = m.xWidthAvg / m.unitsPerEm / (georgia.xWidthAvg / georgia.unitsPerEm);
const pct = (v) => `${((v / (m.unitsPerEm * sizeAdjust)) * 100).toFixed(2)}%`;Which produced this, for one text face against Georgia:
@font-face {
font-family: 'Text Fallback';
src: local('Georgia');
size-adjust: 98.92%;
ascent-override: 77.03%;
descent-override: 24.06%;
line-gap-override: 23.86%;
}
body { font-family: 'Text', 'Text Fallback', Georgia, serif; }Last, preload only what is painted in the first screen. On the site above that is four faces out of five, 43.8 KB, and the italic waits:
<link rel="preload" href="/assets/fonts/text.293e1fd9f0.woff2" as="font" type="font/woff2" crossorigin>The crossorigin attribute is required even for a same origin font, because fonts are fetched in CORS mode. Without it the browser downloads the file twice. Serving the files yourself rather than from a third party is a separate decision with its own reasons, but it is what makes the hashed filename and the immutable cache header possible.
How to check it worked
The bytes are easy. The shift needs a test that does not depend on network timing, so force the fallback and compare heights directly:
const article = document.querySelector('article');
const measure = (family) => {
article.style.fontFamily = family;
return article.getBoundingClientRect().height;
};
console.log(measure("'Text', 'Text Fallback', Georgia, serif"),
measure("'Text Fallback', Georgia, serif"));
// 4112 4112If those two numbers are equal, no arrival of the web font can move anything, whatever the connection does. That is a stronger statement than a good score on one run, and it is a test you can keep.
What to watch out for
- Subsetting deletes glyphs you did not know you needed. A currency symbol in a price, a name with a letter outside the pool, a month name from a locale you added last month: each one falls back to a different font mid sentence and looks like a rendering bug. Generate the pool automatically and add a check that fails the build when a content string contains a character the subset does not have.
size-adjustscales the face, so any measurement you made inemagainst the fallback moves with it. Put the overrides on the fallback face only, never on the real one.local()only resolves if that font is installed. Georgia is on Windows and macOS and not on Android, so on Android the stack falls through to a generic serif and the match is approximate. Accept that, or add a second override block for a font that platform does have.- Preloading everything is worse than preloading nothing. Each preload is fetched at high priority and competes with the document and the largest image.
- Variable fonts need the axis kept or an instance pinned at subset time. Subsetting a variable font and forgetting the axis gives you a file that renders at the wrong weight everywhere.
The habit worth keeping from this is not the specific properties, it is where the numbers came from. Nobody guesses that a masthead set in two letters is carrying seventy kilobytes of glyphs, and nobody guesses that a swap is free once the metrics agree. Both of those are one measurement away, and both of them are fixed in the build rather than at runtime, which means they stay fixed. Fonts are the rare performance problem where the correct answer is fully determined by content you already have on disk.
Questions and answers
- Does font-display: swap cause cumulative layout shift?
- Not on its own. The shift happens because the fallback font sets the same text at a different width and height, so when the real font arrives the line breaks change and everything below moves. If you override the fallback metrics so both fonts produce identical line boxes, swap becomes free and you keep the benefit of text being readable immediately.
- How do I decide which characters to keep in a subset?
- Generate the list from the content at build time rather than choosing a Unicode range. Walk every string the site can print, add the uppercase forms if you set anything in capitals, add plain ASCII, the punctuation you use, the soft hyphen, and every month and weekday name your date formatters can produce in every locale you support. Anything you leave out will silently render from a fallback font.
- What does size-adjust actually do?
- It scales the glyphs of a face by a percentage without changing the declared font-size, so you can make a fallback font have the same average character width as the web font. Combined with ascent-override and descent-override, which set the line box height independently of the font's own metrics, it lets a locally installed font stand in for a web font pixel for pixel.
- How many fonts should I preload?
- Only the faces that are actually painted in the first screen, which is usually one or two. A preload is a promise to the browser that this file is needed immediately, and each one takes bandwidth away from the HTML and from the largest image. Italic, small caps and anything below the fold should not be preloaded.