
How to load fonts asynchronously without hurting Core Web Vitals?
- Sajjad
- Typography
- 11 Oct, 2026
To load fonts asynchronously without hurting Core Web Vitals, let text render immediately in a fallback font, fetch the font files early, and make sure the swap to the web font doesn't move anything. In practice that means setting font-display: swap or optional in every @font-face rule, self-hosting a small number of subsetted WOFF2 files, preloading the one or two fonts used above the fold, and loading any third-party font stylesheet without blocking rendering. You then pair the web font with a metric-matched fallback so the swap causes little or no layout shift. Done together, these steps protect Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS), the two Core Web Vitals that fonts affect most.
Fonts are one of the few resources that can both delay content and move it after it appears. A render-blocking font stylesheet on a slow connection can push LCP back by a second or more, and a badly matched fallback can make a whole article jump when the web font arrives. In this article you'll learn how fonts interact with each metric, how to load them without blocking, when to use preload, how to use the CSS Font Loading API, and how to measure the result on real devices.
How Fonts Affect Each Core Web Vital
Core Web Vitals currently consist of three metrics. Fonts affect them in different ways.
- Largest Contentful Paint (LCP): Measures when the largest visible element finishes rendering. If that element is a heading or paragraph, its paint time can depend on when text becomes visible. Invisible text while a font loads delays LCP. The good threshold is 2.5 seconds or less.
- Cumulative Layout Shift (CLS): Measures unexpected movement of visible content. When a web font replaces a fallback with different proportions, lines rewrap and elements move. The good threshold is 0.1 or less.
- Interaction to Next Paint (INP): Measures how quickly the page responds to input. Fonts rarely affect INP directly, but heavy JavaScript font loaders running on the main thread can add to the work the browser has to do.
Most font optimisation is therefore about two goals: show text quickly, and keep it still.
Step 1: Choose the Right font-display Value
The font-display descriptor in each @font-face rule controls what the browser does while a font downloads.
@font-face {
font-family: "Source Serif";
src: url("/fonts/source-serif-4-var.woff2") format("woff2");
font-weight: 200 900;
font-style: normal;
font-display: swap;
}
The values you're likely to use are:
| Value | Block period | Swap period | Effect on Core Web Vitals |
|---|---|---|---|
auto | Browser default, usually about 3 seconds | Infinite | Can delay LCP while text is invisible |
block | About 3 seconds | Infinite | Same as above, deliberately |
swap | Very short | Infinite | Fast LCP, risk of CLS on swap |
fallback | About 100 ms | About 3 seconds | Balanced, late fonts are skipped |
optional | About 100 ms | None | Best for CLS, font may not show on first visit |
For most body text, swap is the practical default as long as you also tune the fallback (step 5). If layout stability matters more than showing the web font on the very first visit, optional is the safest choice: the browser uses the web font only if it's ready almost immediately, otherwise it keeps the fallback for that page view and uses the cached font next time.
Text painted in a fallback font is eligible for LCP, so with swap the first paint of a heading or paragraph usually counts and the later swap to the web font doesn't push LCP back. Invisible text during a block period, on the other hand, delays it directly.
Step 2: Serve Fewer, Smaller Font Files
Every font file competes for bandwidth with your images, scripts and styles. Reducing what you load is the most reliable optimisation.
- Use WOFF2 only: Every browser that matters supports it, and it compresses better than WOFF or TTF.
- Use variable fonts where they replace several files: One variable file covering weights 300 to 700 is often smaller than three static files.
- Subset to the characters you need: Removing unused scripts and glyphs can cut file sizes dramatically.
- Drop weights and styles you don't use: Audit your CSS. Many sites load a light italic that appears nowhere.
Subsetting with pyftsubset from fontTools looks like this:
pip install fonttools brotli
pyftsubset SourceSerif4-Variable.ttf \
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02C6,U+02DA,U+02DC,U+2000-206F,U+20AC,U+2122" \
--layout-features="kern,liga,calt,onum,lnum,tnum" \
--flavor=woff2 \
--output-file=source-serif-4-var-latin.woff2
Check the sizes before and after:
ls -lh SourceSerif4-Variable.ttf source-serif-4-var-latin.woff2
If you split a font into several subsets, use unicode-range so the browser only downloads the files it needs for the characters on the page.
Step 3: Self-Host and Avoid Render-Blocking Stylesheets
A font stylesheet in the head is render-blocking. If it comes from a third-party domain, the browser first has to resolve DNS, open a connection, download the CSS, and only then discover the font URLs. That chain is a common cause of slow LCP.
Self-hosting removes the extra connection and lets you put @font-face rules directly in your main CSS:
<link rel="stylesheet" href="/css/main.css">
/* main.css */
@font-face {
font-family: "Source Serif";
src: url("/fonts/source-serif-4-var-latin.woff2") format("woff2");
font-weight: 200 900;
font-display: swap;
}
body {
font-family: "Source Serif", "Source Serif Fallback", Georgia, serif;
}
If You Must Use a Hosted Font Service
If you keep using Google Fonts or another hosted service, warm up the connections and load the stylesheet without blocking rendering:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;700&display=swap"
media="print"
onload="this.media='all'">
<noscript>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@400;700&display=swap">
</noscript>
The media="print" trick tells the browser the stylesheet isn't needed for the screen, so it downloads at low priority without blocking rendering. Once loaded, the onload handler switches it to all. The noscript block covers users without JavaScript. Note that display=swap in the URL sets font-display for you in the generated CSS.
This approach makes rendering faster, but text will always start in the fallback, so a matched fallback becomes even more important. If your site uses a strict Content Security Policy that disallows inline event handlers, the onload attribute won't run, and self-hosting is the better route.
Step 4: Preload Only the Critical Fonts
Fonts are discovered late. The browser has to download and parse the CSS, build the render tree, and find text that uses a font before it requests the file. A preload hint lets it start the download as soon as it reads the HTML:
<link rel="preload"
href="/fonts/source-serif-4-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin>
Keep these rules in mind:
- Always include
crossorigin: Fonts are fetched in anonymous CORS mode, even from your own domain. Without the attribute the preload is wasted and the font is downloaded twice. - Preload one or two files at most: Usually the body text font and perhaps the heading font. Preloading everything competes with your LCP image and CSS.
- Make sure the URL matches exactly: The preload
hrefmust match thesrcURL in@font-face, including any query string. - Don't preload fonts that only appear below the fold: They'll load in time when needed.
Chrome DevTools warns in the console when a preloaded resource isn't used within a few seconds, which is a quick way to spot a mismatched URL.
Step 5: Match the Fallback to Prevent CLS
With swap, the fallback is what users see first. If it has a different width or line box, the swap shifts content. You can create an adjusted fallback face that points at a local font and scales it:
@font-face {
font-family: "Source Serif Fallback";
src: local("Georgia");
size-adjust: 96%;
ascent-override: 95%;
descent-override: 30%;
line-gap-override: 0%;
}
The values above are illustrative. Calculate real ones from the font metrics or generate them with a tool such as Capsize or Fontaine. The details are covered in font fallbacks and size-adjust.
Also set an explicit line-height on body text. A unitless value such as 1.6 gives every font the same line box height, which removes much of the vertical shift on its own.
Step 6: Use the CSS Font Loading API for Fine Control
For more control than CSS alone gives you, the Font Loading API lets you load fonts in JavaScript and react when they're ready. A common pattern is to load the regular weight first, then load the bold and italic files in a second stage and apply them together, so the page repaints once rather than several times.
async function loadFonts() {
if (!("fonts" in document)) return;
// Stage 1: the critical font (also declared in CSS with font-display: swap)
await document.fonts.load('400 1em "Source Serif"');
document.documentElement.classList.add("fonts-stage-1");
// Stage 2: secondary styles, applied together
await Promise.all([
document.fonts.load('700 1em "Source Serif"'),
document.fonts.load('italic 400 1em "Source Serif"'),
]);
document.documentElement.classList.add("fonts-stage-2");
}
loadFonts().catch(() => {
// Leave the fallback in place if loading fails
});
body {
font-family: "Source Serif Fallback", Georgia, serif;
}
.fonts-stage-1 body {
font-family: "Source Serif", "Source Serif Fallback", Georgia, serif;
}
.fonts-stage-2 strong,
.fonts-stage-2 em {
font-family: "Source Serif", "Source Serif Fallback", Georgia, serif;
}
You can also check readiness for testing or analytics:
document.fonts.ready.then(() => {
console.log("Fonts loaded:", [...document.fonts].filter(f => f.status === "loaded").length);
});
Keep this kind of script small and inline or deferred. A large third-party loader that runs before first paint undoes the benefit.
Remember Repeat Visitors
On repeat visits, fonts are usually in the browser cache. Serve font files with a long cache lifetime and versioned file names so they're never re-downloaded unnecessarily:
Cache-Control: public, max-age=31536000, immutable
Step 7: Measure the Result
Lab tools show you what's possible, but Core Web Vitals are judged on real user data. Measure both.
In the Lab
- Open Chrome DevTools, go to the Network panel and filter by Font to see when each file starts and finishes.
- Use the Performance panel with network throttling to record a load. Look for the layout shift track around the time fonts arrive.
- Run Lighthouse and check for "Ensure text remains visible during webfont load" and render-blocking resource warnings.
In the Field
The web-vitals library reports the same metrics Google collects from real Chrome users:
npm install web-vitals
import { onLCP, onCLS, onINP } from "web-vitals";
function send(metric) {
navigator.sendBeacon("/analytics", JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
}));
}
onLCP(send);
onCLS(send);
onINP(send);
Compare the numbers before and after each change. If CLS improves but LCP gets worse, you may have preloaded too much. If LCP improves but CLS rises, your fallback needs tuning.
A Quick Checklist
- Every
@font-facehasfont-display:swapfor most text,optionalwhere stability matters most. - WOFF2, subsetted, as few files as possible: Variable fonts if they replace several statics.
- Self-hosted, or loaded without blocking rendering: With
preconnectif third-party. - One or two preloads: With
crossoriginand exact URLs. - Metric-matched fallback: Plus an explicit
line-height. - Long cache lifetimes: With versioned file names.
- Field data checked: LCP, CLS and INP measured on real users.
FAQ: Loading Fonts and Core Web Vitals
It can, because text is shown in a fallback first and then re-rendered in the web font. If the two fonts have different proportions, content moves. A metric-matched fallback and an explicit line-height keep the shift very small.
For CLS, yes, because the font is only used if it's ready almost immediately. The trade-off is that first-time visitors on slow connections may see the fallback for the whole page view.
No. Preload only the one or two files needed for text above the fold. Preloading too many fonts competes for bandwidth with your CSS and LCP image and can make LCP worse.
Usually because the crossorigin attribute is missing or the URL doesn't exactly match the one in the font-face rule. Fonts are always requested in CORS mode, so the preload must be too.
Rarely. Font files load off the main thread. INP can suffer only if you use a heavy JavaScript font loader or trigger large re-layouts while the user is interacting.
Often, because self-hosting avoids an extra connection to another domain and lets you put font-face rules in your own CSS. Browsers no longer share cached fonts between sites, so the old shared-cache argument for hosted fonts no longer applies.
Conclusion
Loading fonts asynchronously is less about a single trick and more about removing every delay between the HTML and visible text. Use font-display so text paints straight away, cut the font payload with WOFF2, subsetting and variable fonts, self-host or load hosted stylesheets without blocking rendering, and preload only the files that render above the fold.
Then deal with the swap itself. A fallback tuned with size-adjust and the override descriptors, plus a fixed line-height, keeps CLS low when the web font arrives. Measure with DevTools and the web-vitals library on real visitors, and adjust until both LCP and CLS sit comfortably inside the good thresholds.


