Self-Hosted Web Fonts Without the Layout Shift

·


Self-hosting your web fonts is the right call: no third-party requests, no consent implications, full control over caching. But most guides stop at “download the woff2 and write an @font-face rule,” which quietly ships two performance problems: a flash of mismatched text and a layout shift when the real font arrives. Here is the complete setup I use on this site, from subsetting to preloading, with the WordPress-specific pieces included.

Step 1: subset ruthlessly

A full font family with every script and weight can run past 300KB. You almost certainly need a fraction of that. Two decisions cut the most weight: use variable fonts, so one file covers every weight from 300 to 700, and subset to the character ranges you actually publish in. My two families, Space Grotesk for headings and Inter for body text, are Latin-subset variable fonts weighing 23KB and 49KB together. That is the difference between a preload being cheap and a preload competing with your CSS for bandwidth.

The @font-face rules declare the full weight range and the unicode range of the subset:

@font-face {
	font-family: 'Space Grotesk';
	font-style: normal;
	font-weight: 300 700;
	font-display: swap;
	src: url('../fonts/SpaceGrotesk-Variable-Latin.woff2') format('woff2');
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122;
}

Step 2: understand what font-display: swap actually costs

font-display: swap means the browser paints text immediately in a fallback font, then swaps in your web font when it arrives. That is the right default: no invisible text, content readable from the first frame. The cost is the swap itself. Your heading set in the fallback occupies a different number of pixels than the same heading in your web font, so the swap reflows everything below it. On my homepage that one reflow measured 0.118 of Cumulative Layout Shift, more than the entire “good” budget of 0.1.

Step 3: preload so the swap window closes

Without a preload, the browser discovers your fonts late. It has to download the CSS, parse it, match @font-face rules against the page, and only then request the woff2 files. Preloading moves that discovery to the HTML head, so small fonts are usually in the cache before the browser paints anything. The swap still technically happens; it just happens before the first frame, where nobody can see it.

WordPress has a proper API for this since 6.1, the wp_preload_resources filter:

function mytheme_preload_fonts( $resources ) {
	// Must match the @font-face sources in your stylesheet.
	foreach ( array( 'SpaceGrotesk-Variable-Latin.woff2', 'Inter-Variable-Latin.woff2' ) as $font ) {
		$resources[] = array(
			'href'        => get_template_directory_uri() . '/assets/fonts/' . $font,
			'as'          => 'font',
			'type'        => 'font/woff2',
			'crossorigin' => 'anonymous',
		);
	}
	return $resources;
}
add_filter( 'wp_preload_resources', 'mytheme_preload_fonts' );

The non-obvious detail is crossorigin => ‘anonymous’. Font fetches always use CORS mode, even for files on your own domain. A preload without the crossorigin attribute uses a different credentials mode than the eventual @font-face fetch, the cache entries do not match, and the browser downloads every font twice while the preload accomplishes nothing. If you take one thing from this post, take that.

Step 4: verify with numbers, not vibes

Three checks confirm the setup works. First, view source and confirm the link rel=”preload” tags appear in the head with the crossorigin attribute. Second, open DevTools, filter the network panel to font requests, and confirm each font downloads exactly once, early in the waterfall. A duplicated font download means the crossorigin attributes do not match. Third, run Lighthouse and look at the layout shift audit. My CLS went from 0.129 to under 0.01 with the preload in place, with the fonts themselves unchanged.

When this recipe is not enough

Preloading works because my font files are small. If your fonts are large or numerous, preloading them all will delay your Largest Contentful Paint on slow connections, trading one metric for another. At that point the better tools are font-display: optional, which skips the swap entirely for late-arriving fonts, or metric-compatible fallbacks using size-adjust and ascent-override so the swap does not move anything. Both are more fiddly than a preload, which is why subsetting comes first: small fonts make the simple solution sufficient.

The complete setup is two font files, one stylesheet, and a twelve-line filter. Self-hosted, private, fast, and stable. That is the whole point of owning your stack.