Font Loading Guide: font-display, size-adjust, Preloading, and Variable Fonts

Web fonts are one of the most misunderstood performance topics in front-end development. Every site uses them; very few sites load them optimally. The decisions you make around font-display, preloading, size-adjust, and font hosting directly affect your Largest Contentful Paint, First Contentful Paint, and Cumulative Layout Shift scores. A single render-blocking font can push LCP past 4 seconds. An uncompensated font swap can produce a CLS score of 0.3 or worse. And those effects compound across every page of your site, affecting every real user on every network. This guide walks through every lever you can pull to load fonts fast, avoid layout shifts, and keep your Core Web Vitals in the green.

How web fonts hurt LCP, CLS, and FCP

The browser cannot render text until it knows which font to use. When a font is declared in CSS, the browser does not begin fetching it until the CSS is parsed and the font is determined to be needed by visible text. This late discovery means fonts nearly always arrive after the first render, creating one of two problems: the browser either hides text entirely (Flash of Invisible Text, or FOIT) or renders text in a fallback system font and later swaps to the web font (Flash of Unstyled Text, or FOUT).

The connection chain for a Google Fonts stylesheet adds even more delay. Before the browser can fetch a single font file, it must: resolve the DNS for fonts.googleapis.com, complete a TCP handshake and TLS negotiation, fetch the CSS stylesheet, parse the stylesheet to discover the actual fonts.gstatic.com font file URLs, resolve a second DNS entry, complete a second TCP/TLS handshake, and finally download the WOFF2 file. Each step adds latency, and on mobile networks with 100-200 ms round trips, this chain routinely adds 500-800 ms to First Contentful Paint.

FOIT vs. FOUT and their Core Web Vitals impact

FOIT (the browser default for fonts without a font-display descriptor) suppresses text for up to three seconds while the font loads. During those three seconds, the page has no visible text content, which means the LCP element -- if it is text -- cannot be painted. FCP is delayed by the same amount. Even for image-based LCP, invisible text means a worse user experience and a lower perceived performance score.

FOUT with font-display: swap solves the invisibility problem but introduces a different one: when the web font arrives and replaces the system fallback, characters in the web font are almost never the same width as in the fallback. A heading that wraps at 40 characters in Arial may wrap at 38 in your custom typeface. That reflow shifts the entire document, driving up Cumulative Layout Shift. On pages where a large text block sits above a call-to-action button, the shift can move the button under the user's finger mid-click -- precisely the kind of experience the CLS metric was designed to catch.

Understanding this trade-off is the foundation of every font performance decision. You are always balancing the desire to show your custom typeface as early as possible against the performance cost of doing so. The good news is that modern CSS gives you enough control to have both: a fast first paint and a shift-free swap.

font-display: swap vs optional vs fallback

The font-display descriptor inside @font-face rules controls how the browser handles the gap between when text needs to be rendered and when the font file is available. There are five values: auto, block, swap, fallback, and optional. The three most relevant to Core Web Vitals are swap, fallback, and optional.

CSS
/* Self-hosted @font-face with font-display */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: optional; /* best for LCP and CLS */
  src:
    url('/fonts/inter-v13-latin-regular.woff2') format('woff2'),
    url('/fonts/inter-v13-latin-regular.woff') format('woff');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
                 U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
                 U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
                 U+FEFF, U+FFFD;
}

/* For body text where fallback is acceptable on first load */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 700;
  font-display: optional;
  src: url('/fonts/inter-v13-latin-700.woff2') format('woff2');
}

font-display: swap

swap gives the font an extremely short block period (roughly 100 ms) followed by an infinite swap period. Text is shown immediately in the fallback font and swapped to the web font whenever it arrives. The upside is that the user always sees your custom typeface eventually. The downside is that the swap itself can cause CLS if the fallback and web font have different metrics. Use swap when your brand typeface is critical for recognition -- a newspaper masthead, a logo font -- and when you have invested effort in size-adjust to neutralize the shift.

font-display: optional

optional gives the font a very short block period (100 ms) and then a zero-length swap period. If the font has not loaded within approximately 100 ms, the browser uses the fallback for the entire page session and does not swap later even if the font eventually arrives. The font is cached for subsequent page loads, where it will be used from the start. This is the best choice for Core Web Vitals: no CLS from swapping, and LCP is never delayed by waiting for a font. The trade-off is that first-time visitors on slow connections will see the fallback font.

font-display: fallback

fallback sits between swap and optional. It gives the font a short block period (100 ms) and a limited swap window (around 3 seconds). If the font arrives within 3 seconds, it swaps. If not, the fallback font is used for the session. This is a reasonable compromise for brands that want to show the custom font on fast connections while protecting slow-connection users from late, disruptive swaps.

Eliminating font CLS with size-adjust and metric overrides

Even with font-display: swap, you can achieve near-zero CLS if you match the fallback font metrics to your web font. The CSS @font-face specification provides four descriptors specifically for this purpose: size-adjust, ascent-override, descent-override, and line-gap-override. Together they allow you to reshape a system font to match the geometry of your custom typeface so that when the swap occurs, the text occupies the same space and no layout shift results.

The process starts with measuring your web font. Tools like the Fallback Font Generator and Chrome DevTools font metrics panel can extract the ascent, descent, line gap, and advance width values from a font file. You then apply those values as overrides to a declaration for a local system font.

CSS
/* Step 1: Declare the web font */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/inter-regular.woff2') format('woff2');
}

/* Step 2: Create a metric-adjusted fallback */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  /* size-adjust scales the fallback glyph widths to match Inter */
  size-adjust: 107%;
  /* Override vertical metrics to match Inter's line box */
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* Step 3: Use both in the font stack */
body {
  font-family: 'Inter', 'Inter Fallback', Arial, sans-serif;
}

With this setup, the browser renders text using "Inter Fallback" -- which is actually Arial resized and repositioned to approximate Inter's layout -- until Inter itself downloads. When Inter swaps in, the characters occupy virtually the same space, producing a CLS score of 0.00 or very close to it. The visual change from Arial-shaped to Inter-shaped glyphs is subtle enough that most users do not perceive it as a layout shift even though the shapes change.

The values for size-adjust and the override descriptors must be calibrated per font pair. Arial adjusted for Inter is different from Arial adjusted for Roboto. Automating this calibration is part of what tools like Next.js next/font and Nuxt Google Fonts do for you -- they compute and inject the override values at build time.

The font loading CLS fix guide walks through the complete measurement and calibration process with specific values for the most common web fonts. If your site uses a custom brand typeface rather than a well-known library font, you will need to run the measurement yourself using one of the tooling options described there.

Self-hosting vs Google Fonts vs Fontshare

Where you host your font files has a direct and measurable impact on performance. The decision affects connection overhead, caching, privacy compliance, and the degree of control you have over the loading process.

The cost of Google Fonts

Google Fonts is free and carries an enormous library, but the performance model has real costs. Loading a Google Fonts stylesheet adds two cross-origin connections: one to fonts.googleapis.com for the CSS and one to fonts.gstatic.com for the actual font files. Even with preconnect hints, these connections add latency. More importantly, the fonts are served from Google's CDN, not yours, so they cannot share an HTTP/2 connection with your page assets. Browser HTTP/2 multiplexing only works within a single origin.

Privacy regulations in some jurisdictions (notably Germany under GDPR interpretations) have ruled that loading Google Fonts in a way that sends user IP addresses to Google constitutes a data transfer requiring consent. Self-hosting eliminates this concern entirely.

Fontshare

Fontshare, operated by the Indian Type Foundry, offers a curated library of high-quality typefaces under a free license for personal and commercial use. Its loading model is identical to Google Fonts -- you get a CSS embed URL and the fonts are served from Fontshare's CDN. The same cross-origin latency costs apply, but the font quality is often superior to Google Fonts for editorial and product UI use cases.

Self-hosting: the performance-optimal path

Self-hosting means downloading the WOFF2 files, placing them on your own server or CDN, and writing your own @font-face rules. This eliminates all cross-origin overhead. The browser fetches fonts from the same origin as your HTML and CSS, sharing the existing HTTP/2 connection and benefiting from your CDN edge caching. You also control Cache-Control headers, allowing long-lived caching (e.g., one year) with cache-busting via filename hashing.

Tools that simplify this workflow:

  • google-webfonts-helper (gwfh.mranftl.com) -- generates ready-to-use CSS and downloads the WOFF2 files for any Google Font.
  • Fontsource -- an npm package ecosystem; install a font as a package and import it in JavaScript, with automatic subsetting and tree-shaking.
  • fonttools / pyftsubset -- Python-based tools for subsetting font files to specific unicode ranges, reducing file size by removing glyphs you do not need.

The web fonts performance fix provides a step-by-step self-hosting walkthrough with correct Cache-Control headers and CDN configuration for Cloudflare, Fastly, and AWS CloudFront.

Variable fonts: smaller bundles, more weight options

Variable fonts are a fundamental change to how font files work. Introduced in OpenType 1.8 (2016) and now supported in all modern browsers, variable fonts encode the entire design space of a typeface -- every weight, width, slant, and optical size -- in a single file using mathematical interpolation between defined masters. Instead of four separate files for Regular, Italic, Bold, and Bold Italic, you get one file that can produce any point along the weight axis.

For a typical website using a font in weights 300, 400, 500, 600, and 700 (Regular, Medium, SemiBold, and Bold, plus a light variant), the traditional approach would require five WOFF2 files. Each averages about 15-25 KB for a Latin-range subset, adding up to 75-125 KB. A variable font for the same family is typically 40-60 KB -- a reduction of 50-60 percent. That difference is real bandwidth on every page load for every user.

CSS
/* Variable font @font-face -- one file covers all weights */
@font-face {
  font-family: 'Inter Variable';
  font-style: normal;
  font-weight: 100 900; /* declares the full weight range */
  font-display: optional;
  src: url('/fonts/inter-variable.woff2') format('woff2-variations');
  font-named-instance: 'Regular';
}

/* Use any weight value in this range */
h1 { font-weight: 750; } /* not possible with static fonts */
p  { font-weight: 400; }
strong { font-weight: 600; }

/* Width axis, if supported by the font */
@font-face {
  font-family: 'Roboto Flex';
  font-style: normal;
  font-weight: 100 900;
  font-stretch: 75% 125%; /* variable width axis */
  font-display: swap;
  src: url('/fonts/roboto-flex.woff2') format('woff2-variations');
}

Subsetting variable fonts

Variable fonts can be larger than a single static weight file, so subsetting to your required unicode range and axes is important. The pyftsubset tool from the fonttools library handles variable font subsetting. If you only use the weight axis and Latin characters, a subset variable font can be as small as 18-25 KB while still giving you the full weight range.

Not all typefaces have variable font versions. Check the Google Fonts Variable Fonts page or the variable fonts directory at v-fonts.com for available options. When a variable font is not available for your chosen typeface, consider whether the font choice itself is worth the added payload, or whether a visually similar typeface with a variable font version would serve as well.

Preloading and resource hints for fonts

The browser cannot discover a font file until it has parsed the CSS that references it. For a typical render path -- HTML arrives, browser parses it, discovers a CSS link, fetches the CSS, parses the CSS, finds a @font-face rule, determines a matching element exists in the DOM, then finally requests the font -- the font fetch begins hundreds of milliseconds into the load sequence. By then, the First Contentful Paint opportunity has often passed.

<link rel="preload"> moves the font fetch to the earliest possible moment, immediately after the HTML is parsed. The browser begins downloading the font in parallel with the CSS, the first-party JavaScript, and any other critical resources.

HTML
<!-- Preload the primary body font WOFF2 -->
<link
  rel="preload"
  href="/fonts/inter-variable.woff2"
  as="font"
  type="font/woff2"
  crossorigin
>

<!-- If using Google Fonts, preconnect to reduce handshake overhead -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- DNS prefetch as a lower-priority fallback for older browsers -->
<link rel="dns-prefetch" href="https://fonts.gstatic.com">

Three critical rules govern font preloading:

  1. Always include crossorigin, even for same-origin fonts. Fonts are always fetched in CORS mode. Without the attribute, the browser fetches the font twice -- once for the preload and once when it encounters the @font-face rule -- doubling the download cost.
  2. Preload only what renders above the fold. Preloading a bold weight used only in footers or a decorative headline font used below the fold wastes early bandwidth that could be used by your LCP image or critical CSS.
  3. Match the preloaded URL exactly to the src in your @font-face. Any mismatch -- even a trailing query string -- causes the browser to treat them as different resources and download the font a second time.

103 Early Hints

HTTP 103 Early Hints is a server-sent response that can instruct the browser to preload resources before the full HTML response arrives. If your server or CDN supports it (Cloudflare, Fastly, and Vercel all do), sending an Early Hints response with Link: </fonts/inter-variable.woff2>; rel=preload; as=font; crossorigin can start the font download even before the browser receives the first byte of HTML. This is the earliest possible font fetch and can meaningfully improve FCP on pages where text is the primary content.

Framework integrations (Next.js next/font, Astro, Nuxt)

Modern JavaScript frameworks now ship first-class font optimization APIs that automate the best practices described in this guide. Using them is almost always preferable to manual configuration because they handle subsetting, fallback metric calculation, and preload injection automatically.

Next.js next/font

Introduced in Next.js 13, next/font downloads Google Fonts or local fonts at build time, self-hosts them from your own domain, injects @font-face rules with correct font-display values, generates size-adjust fallback metrics, and adds preload links to the document head -- all without any runtime JavaScript. This is the single biggest font performance improvement available in the Next.js ecosystem.

JavaScript
// app/layout.tsx -- Next.js App Router font setup
import { Inter } from 'next/font/google';
import { localFont } from 'next/font/local';

// Google Font: downloaded at build, self-hosted, fallback generated
const inter = Inter({
  subsets: ['latin'],
  display: 'optional',      // best for CLS and LCP
  variable: '--font-inter', // CSS variable for use in Tailwind
  preload: true,
  adjustFontFallback: true, // generates size-adjust fallback automatically
});

// Local custom font with variable font support
const brandFont = localFont({
  src: [
    {
      path: '../public/fonts/brand-variable.woff2',
      weight: '100 900', // variable font weight range
      style: 'normal',
    },
  ],
  variable: '--font-brand',
  display: 'swap',
  preload: true,
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${brandFont.variable}`}>
      <body>{children}</body>
    </html>
  );
}

See the CLS in React fix guide for more on how next/font eliminates CLS in React-based projects, including configuration options for App Router and Pages Router.

Astro

Astro integrates with @astrojs/fonts (or the community astro-google-fonts-optimizer package) to inline critical font CSS, generate preload hints, and self-host fonts from your build output. Because Astro ships zero JavaScript by default, font loading in Astro is especially clean: there is no client-side font management JavaScript at all. You declare fonts in your Astro config or frontmatter, and the framework handles the rest at build time.

Nuxt

Nuxt 3 provides the @nuxt/fonts module, which automatically detects font usage in your CSS and Vue components, downloads those fonts from Google Fonts or Bunny Fonts at build time, self-hosts them, and injects optimized @font-face declarations with font-display: swap and computed metric overrides. The module requires no manual @font-face authoring -- you write normal CSS font-family declarations and the module resolves, downloads, and optimizes the matching fonts.

Common mistakes

Font loading antipatterns appear in production sites every day. These are the errors most likely to cause measurable Core Web Vitals regressions:

  • Loading fonts without any font-display descriptor. The browser default (font-display: auto) maps to block behavior in most implementations, suppressing text for up to three seconds. This is the single most common font performance error and it is trivially easy to fix.
  • Preloading fonts that are not used above the fold. Preloading a decorative display font used only in a below-fold testimonials section pushes that font file into the critical resource queue, competing with the LCP image and delaying First Contentful Paint. Preload only fonts visible without scrolling.
  • Missing crossorigin on font preload links. Without this attribute, the browser ignores the preload hint for the @font-face fetch because CORS mode mismatches. The font ends up downloaded twice, doubling bandwidth consumption.
  • Loading five separate static font files when a variable font is available. Serving five weight files totaling 100 KB when a 45 KB variable font file covers the same design space is a common oversight, especially on sites migrated from older font stacks.
  • Using font-display: swap without metric-adjusted fallbacks. This combination reliably produces CLS. If you use swap, you must invest in size-adjust and override descriptors to prevent layout shifts on font arrival.
  • Loading fonts from Google Fonts in a GDPR-regulated context without consent management. Beyond performance, this is a legal risk. Self-hosting eliminates the concern and usually improves performance at the same time.

Tools and validation

Use these tools to audit, debug, and validate your font loading implementation:

  • Chrome DevTools Network panel -- Filter by "Font" type to see every font request, its initiator, timing waterfall, and transfer size. The waterfall clearly shows whether a font is being fetched with preload priority or discovered late after CSS parsing.
  • Google PageSpeed Insights -- Reports "Ensure text remains visible during webfont load" (the absence of font-display) and "Eliminate render-blocking resources" if a font stylesheet is in the critical path. Provides real-user field data from CrUX to confirm your fixes are working in production.
  • WebPageTest -- The filmstrip view shows exactly when text first renders versus when the web font appears, making FOIT and FOUT clearly visible. The "Font Loading" audit in the opportunities section identifies specific issues.
  • Fallback Font Generator (screenspan.net/fallback) -- Takes a web font and a system font as inputs, then outputs the computed size-adjust, ascent-override, descent-override, and line-gap-override values needed to match them.
  • Wakamaifondue (wakamaifondue.com) -- A browser-based font inspection tool that displays all axes, named instances, OpenType features, and unicode coverage for any font file. Essential for understanding what a variable font can do before you write your CSS.
  • Font Style Matcher (meowni.ca/font-style-matcher) -- An interactive tool for visually comparing a web font and a system fallback side by side and adjusting override values until they match.

For a complete performance audit workflow incorporating font loading, see the LCP guide and the performance glossary. Both cover the broader context in which font performance decisions interact with image loading, server response time, and JavaScript execution.

Frequently asked questions

It depends on your priority. font-display: optional gives the best LCP and CLS scores because it never causes a layout shift and never blocks rendering, but it may show the fallback font on first load. font-display: swap maximizes the chance your custom font is shown but can cause CLS from the fallback-to-custom swap. For most sites, optional combined with a well-tuned fallback via size-adjust is the optimal choice.

Yes. When the browser renders text with a fallback font and then swaps to the web font, the change in character widths and line heights can reflow surrounding layout, causing Cumulative Layout Shift. The CSS size-adjust, ascent-override, descent-override, and line-gap-override descriptors let you match the fallback font metrics to the web font so the swap produces no visible reflow. See the font loading CLS fix for the full calibration process.

Self-hosting is almost always faster. Google Fonts requires an extra DNS lookup and connection to fonts.googleapis.com and fonts.gstatic.com, plus it cannot share HTTP/2 connections with your own domain. Self-hosted fonts load from the same origin, benefit from your CDN, and give you full control over caching headers. The performance gap is especially large on mobile networks. Tools like google-webfonts-helper make the download and @font-face setup straightforward. See the web fonts performance guide for a complete walkthrough.

A traditional font family ships a separate file for each weight and style combination -- Regular, Italic, Bold, Bold Italic -- typically four files averaging 15-25 KB each in WOFF2. A variable font bundles all weights and styles into one file using OpenType variation axes. For a design system that uses five weights, one variable font file of 40-60 KB replaces 100-125 KB of static files, cutting total font payload by 50-60 percent.

Preload fonts that are used in above-the-fold text that is visible on page load without scrolling. Preloading tells the browser to fetch the font at high priority before it discovers the @font-face rule in your CSS. Limit preloads to one or two fonts -- preloading too many files wastes bandwidth and competes with other critical resources like your LCP image. Always include the crossorigin attribute even for same-origin fonts, because fonts are always fetched with CORS.

Fix font performance issues in your stack and deepen your understanding of related metrics:

PP
Priya Patel Font and typography specialist -- WebVitals.tools