You open a website and, for a moment, the headlines aren't there. Or they are, but in a typeface that isn't the brand's, and half a second later everything shifts into place. That's the web font effect: the browser has to download them before it can paint text with them, and how you handle that wait decides whether the text appears instantly or the page moves while you read.
We'll go from what happens in the browser to what you can change today in your CSS and HTML: font-display, WOFF2, subsets, preloading, an adjusted fallback font and how to measure it all.
How fonts affect LCP and CLS
The browser doesn't download a font just because you declared it in @font-face: it waits until there is text on the page that uses it. The download starts late, after the CSS has been fetched and processed. While the file is on its way, the browser has to decide what to do with the text, and there are two classic behaviours:
- FOIT (flash of invisible text): the text is hidden until the font is ready. If it takes long, the user sees blank gaps.
- FOUT (flash of unstyled text): the text is shown straight away in a fallback font and swapped for the web font when it arrives. It is readable from the start, but the change is noticeable.
LCP measures when the largest content element in the viewport is painted. Often it's an image, but many times it's a headline or a big paragraph. If that text is hidden while waiting for the font, LCP can't happen until the font arrives. Google considers an LCP of 2.5 seconds or less to be good, measured at the 75th percentile of real visits.
CLS measures unexpected content movement. If the fallback font is wider or taller than the final one, when it is swapped the lines break differently and blocks grow or shrink, pushing down whatever is below. A good CLS is 0.1 or less.
There is no free setting: hiding the text avoids the jump but delays LCP; showing it sooner speeds up LCP but risks CLS. The way out is twofold: get the font there sooner (format, size, preloading) and make the swap barely noticeable (an adjusted fallback).
font-display: what each value does
The font-display property, inside @font-face, tells the browser how long to wait and what to show. It works with two periods: the block period (invisible text) and the swap period (text in the fallback, which is swapped if the font arrives). Exact timings are decided by the browsers; those in the table are approximate.
| Value | Block | Swap | When to use it |
|---|---|---|---|
auto | Up to the browser | Up to the browser | Better not to leave it as the default |
block | About 3 s | Infinite | Icon fonts |
swap | Almost none | Infinite | General choice, with an adjusted fallback |
fallback | About 100 ms | About 3 s | Compromise if a late swap worries you |
optional | About 100 ms | None | Secondary fonts or zero CLS |
My rule of thumb: swap with an adjusted fallback for almost everything. fallback if you worry about the swap happening when the user is already reading. optional if zero CLS is the priority and you accept that on the first visit the fallback may be what people see (with preloading it has a better chance of arriving in time). block, only for icon fonts. And don't leave auto: its behaviour depends on the browser.
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
WOFF2, variable fonts and subsets
Today one format is enough: WOFF2. It compresses with Brotli, weighs noticeably less than TTF or OTF and is supported by every current browser (check the compatibility table if you have to support something very old). EOT was for old versions of Internet Explorer, and TTF, OTF or WOFF1 only add weight and lines of CSS. A single URL in src is enough. Since WOFF2 is already compressed, don't expect extra savings from also serving it with gzip; what does matter is that the server delivers it with the right type (font/woff2).
A static font is one file per weight and style: regular, bold, italic and bold italic are four downloads. A variable font stores a continuous range in a single file (for example, weights from 100 to 900). It isn't always lighter: with many axes it can exceed a static file. It usually pays off when you use three or more weights of the same family; with just regular and bold, two subsetted static files may win. Measure both options.
Subsetting is the biggest lever. A complete font includes thousands of glyphs (Cyrillic, Greek, Vietnamese, symbols) and your Spanish website uses a few hundred. With fonttools you can generate a file containing only what's needed: basic Latin, the Latin-1 supplement (accents, ñ, ¿, ¡), typographic punctuation and the euro sign.
pip install fonttools brotli
pyftsubset Inter.ttf --unicodes="U+0020-007E,U+00A0-00FF,U+2013-2014,U+2018-201D,U+2026,U+20AC" --layout-features="*" --flavor=woff2 --output-file=inter-latin.woff2
Check the licence before modifying a font: some require you to change the name when distributing a modified version. And check your content: if you write in Catalan or publish names with uncommon characters, a missing glyph will be painted in the fallback font.
With several languages you can split the font into subsets and declare each one with unicode-range: the browser downloads only the ones the page needs. On a Spanish-only site, a single Latin file is usually the simplest option.
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
unicode-range: U+0020-007E, U+00A0-00FF, U+2013-2014, U+2018-201D, U+2026, U+20AC;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter-latin-ext.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
unicode-range: U+0100-017F;
}
Preloading, self-hosting and Google Fonts
Because the browser discovers the font late, you can tip it off from the HTML with a preload:
<link rel="preload" href="/fonts/inter-latin.woff2" as="font" type="font/woff2" crossorigin>
It comes with several traps:
- The
crossoriginattribute is mandatory, even if the font is on your own domain. Fonts are requested in anonymous CORS mode; without the attribute, the preload isn't reused and the file is downloaded twice. - Preload only the critical ones: one or two, those on the first screen. Every preload competes with the CSS and the LCP image.
- The URL must match the one in
@font-faceexactly. - Don't preload subsets that may not be used: preloading downloads them regardless. Chrome warns in the console if you preload something that isn't used.
A preload doesn't fix a poor setup. Declare the @font-face in the main CSS (or in inlined critical CSS), avoid @import chains and don't depend on a script that loads fonts late: every link in the chain delays the moment the browser knows what it has to download.
If you use Google Fonts, the page requests the CSS from one domain and the files from another (fonts.googleapis.com and fonts.gstatic.com): two new connections, and a CSS file that blocks rendering before the font is discovered. Self-hosted, it goes over the same connection, you control the subset and you can preload it. The old argument that "it will already be in the visitor's cache" no longer holds: the main browsers separate the cache by site, so a download made by another website doesn't benefit you.
Self-hosting also gives you control over caching. With versioned file names (for example, inter-latin-v4.woff2) you can afford a year:
<FilesMatch "\.woff2$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
Privacy weighs just as much. Loading Google Fonts from Google's servers means the visitor's IP address reaches Google. In 2022, a regional court in Munich ruled that loading them this way without consent was unlawful and awarded a visitor damages. It's a first-instance German ruling, not binding in Spain, but it stoked the debate; self-hosting avoids the problem at its root. Check it with a legal adviser. Also check the licence of each family: Google Fonts families usually allow you to download and host the files, but it's worth verifying.
If you stay with Google Fonts: request only the weights you use, keep display=swap and add preconnect for both domains.
A fallback font that doesn't move the page
The jump appears because the fallback and the final font have different metrics: average character width, height above and below the baseline, and their own line spacing. CSS lets you create a "disguised" fallback from a local font and adjust it so it takes up almost the same space:
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter Fallback", Arial, sans-serif;
}
size-adjust scales the glyphs to match the average width; ascent-override and descent-override set the height above and below the baseline, and line-gap-override the intrinsic line spacing, all as a percentage of the font size. The values in the example are indicative: work out those for your font with a tool such as Capsize or Fontaine, or with the automatic generation of some frameworks (Next.js includes it). Keep in mind that local() depends on that font existing on the device and that support for these descriptors isn't identical in every browser: check MDN or Can I use. Where they aren't supported, the site works just the same; only the small jump comes back.
To check the result, block the web font from DevTools (in the Network tab, the file's context menu, "Block request URL") and reload: you'll see the page with the fallback only. Compare it with the final version. If the headline takes the same number of lines and nothing moves, the adjustment is well tuned.
The radical alternative is the system font stack: zero downloads, instant text and no jump, because you use the device's own typeface.
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
The trade-off is that the site looks different on each system and your brand loses personality. A common middle ground: the system stack for long text and a brand font only for headlines, with a single file.
How many fonts to use and what to do about icons
As a rule, one family and at most two; and of each, two or three weights (or one variable font). Every family, weight and style is one more request and one more risk of a jump. Before adding a font, ask yourself whether the same design can be achieved with another weight of the one you already have. And if the italic only appears in two words, it may not deserve a file of its own.
Icons are similar. An icon font downloads hundreds of glyphs even if you use eight; until it arrives you'll see gaps or odd characters; screen readers may read out meaningless symbols, and it only supports one colour. SVG icons, inline or in a sprite, with only the icons you use, weigh less, look sharp at any size and are more accessible (aria-hidden="true" on decorative ones, alternative text on those that convey something).
How to measure and a final checklist
Don't trust your gut: measure before and after.
- DevTools, Network tab: filter by "Font". See how many files there are, how much they weigh and when they start in the waterfall: if they start late, they're discovered late. Disable the cache and throttle the connection (for example, "Slow 4G") to see the worst case.
- DevTools, Performance tab: record a reload and find the LCP marker and the layout shifts. In the Rendering panel, the "Layout Shift Regions" option highlights whatever moves.
- PageSpeed Insights: compare field data (real visits) with lab data. The former is aggregated over a window of roughly four weeks, so it takes a while to reflect a change.
- WebPageTest: the filmstrip and the waterfall show when the text appears. Repeat each test several times and compare with and without your changes.
Final checklist:
- One or two families, with two or three weights (or one variable font).
- WOFF2 only, self-hosted and subsetted for your languages.
font-displaydeclared in every@font-face.- One or two critical fonts preloaded, with
crossoriginand the identical URL. - Fallback font with
size-adjustand the three overrides, or a system stack. - Long cache lifetime and versioned file names.
- Icons in SVG, not in a font.
- LCP and CLS measured before and after, in the lab and in the field.
Frequently asked questions
Is font-display: swap always the best option?
It's the safest place to start, because the text is visible from the first moment. But without an adjusted fallback it can cause layout shifts. If CLS worries you more than seeing the brand font on the first visit, try optional with preloading. It's a design decision as much as a technical one, so it's worth agreeing it with whoever looks after the brand.
Should I preload all my fonts?
No. Preload only those used on the first screen, normally one or two. Preloading too much competes with the CSS and the main image, and can worsen LCP instead of improving it.
I already use Google Fonts and it works fine, is self-hosting worth it?
Usually yes: you save external connections, control caching and subsets, and avoid sending visitor data to a third party. It's a small change on most websites: you download the files, subset them, change the URLs in the CSS and remove the connections to Google's domains. For the legal side, check with an adviser.