LCP TTFB Font Delivery

Web Fonts Performance: Complete Optimization Guide

Web fonts are one of the most impactful and most overlooked causes of poor Largest Contentful Paint and high Time to First Byte perception. Every custom font adds at least one network round-trip before the browser can render styled text. When fonts are served from a third-party host, that round-trip includes a DNS lookup, TCP connection, and TLS handshake -- before a single byte of font data moves. The cumulative effect can push LCP 300-800 ms into the "needs improvement" or "poor" range even on otherwise fast pages.

This guide covers the full surface area of font performance: file formats, delivery architecture, subsetting, loading strategies, framework tooling, and modern browser features. It is distinct from the companion guide on font-loading CLS, which focuses exclusively on eliminating layout shift during the font swap. The focus here is on reducing font payload size and shortening the path from request to render -- improving raw LCP and perceived TTFB.

TL;DR -- what to do right now:
  1. Self-host all fonts; eliminate third-party DNS round-trips.
  2. Subset font files to the character ranges your users actually read (Latin only = ~20 KB vs. ~250 KB full).
  3. Use WOFF2 exclusively; serve with Brotli and long-lived cache headers.
  4. Preload the 1-2 fonts used by above-the-fold text with <link rel="preload" as="font" crossorigin>.
  5. Use font-display: swap for body fonts and font-display: optional for non-critical decorative fonts.

Font formats explained

Modern browsers support three web font formats: TTF/OTF (raw outline fonts), WOFF (compressed TTF/OTF with metadata), and WOFF2 (Brotli-compressed, consistently 20-40% smaller than WOFF). In 2024, WOFF2 has full support in every browser that matters -- including Safari 10+, Chrome 36+, Firefox 39+, and Edge 14+. There is no reason to ship TTF or WOFF to modern browsers; WOFF2 should be your only src value in new projects.

CSS -- Modern @font-face with WOFF2 only
/* Modern: WOFF2 only. No TTF fallback needed. */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var-latin.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900; /* Variable font weight axis */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153,
    U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F;
}

/* Legacy fallback (only if you still need IE11 support) */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-400.woff') format('woff'),
       url('/fonts/inter-400.ttf') format('truetype');
  font-weight: 400;
  font-display: swap;
}

Variable fonts collapse a full type family into a single file. Instead of shipping inter-400.woff2, inter-500.woff2, inter-600.woff2, and inter-700.woff2 (four requests, four cache entries), you ship one inter-var.woff2 that covers every weight along a continuous axis. The single file is typically 20-40% larger than one static weight, but still 50-70% smaller than all four static weights combined. The single-file model also makes preloading trivially simple: one preload tag covers every weight used on the page.

COLRv1 is a newer format for color fonts that replaces SVG fonts and older COLR tables. It uses Brotli-compressed vector layers, making color emoji and decorative color glyphs significantly smaller than PNG-based emoji fonts. If your design uses emoji or branded color glyphs, COLRv1 is supported in Chrome 98+ and Firefox 107+.

For Cyrillic, Greek, and CJK scripts, the unicode-range subsetting strategy is essential. A font covering all CJK ideographs contains 20,000+ glyphs and weighs several megabytes. The approach is to provide multiple @font-face rules with different unicode-range descriptors -- the browser only downloads the subset it actually needs to render the text on the current page. Google Fonts uses this technique automatically; when self-hosting, you replicate it with pyftsubset.

Self-hosting vs. Google Fonts / Typekit / Fontshare

Every third-party font service adds at least two network round-trips before the browser can download a font file. For Google Fonts, the sequence is: DNS lookup for fonts.googleapis.com, TCP + TLS handshake, HTTP request for the CSS (which contains the @font-face rules), then a second DNS lookup for fonts.gstatic.com, another TCP + TLS handshake, and finally the font file download. On a fast connection these round-trips may add 100-200 ms; on a slow mobile connection in a high-latency region, 500-800 ms is common.

Google Fonts (cold)

+480 ms

Median added LCP cost from Google Fonts on first visit, 4G mobile connection, no preconnect hints

Self-hosted (preloaded)

+60 ms

Median added LCP cost from self-hosted, preloaded WOFF2 on same connection

If you cannot self-host, add both preconnect hints to reduce the penalty:

HTML -- Google Fonts preconnect hints
<!-- Add immediately after <head>, before any stylesheets -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- Then load the stylesheet -->
<link
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
  rel="stylesheet"
>

Adobe Fonts (Typekit) uses a JavaScript snippet that is fully render-blocking and adds an additional JS parse step. If you use Typekit, replace the default JS embed with the CSS-only embed option in your kit settings, and add a preconnect for use.typekit.net.

Fontshare is a newer free font CDN from Indian Type Foundry. Its fonts are high quality and its CDN latency is competitive -- but it still incurs external connection overhead. Consider downloading Fontshare fonts (they are free for commercial use) and self-hosting.

Privacy and GDPR considerations: When a user's browser requests a font from Google's servers, Google receives the user's IP address. In May 2022 a German court ruled this violates GDPR. For European audiences or any privacy-sensitive context, self-hosting is the legally safe path -- not just the fast one. The same applies to Typekit and any other third-party font service.

When CDN latency beats self-hosting: If your origin server is geographically far from your users and you lack a CDN, Google Fonts' globally distributed infrastructure may outperform your origin. The rule: self-host if your assets are already on a CDN or edge network; rely on third-party CDN only if your origin latency is demonstrably worse. Measure both options with Lighthouse or WebPageTest before committing.

Font subsetting

Subsetting is the single highest-leverage optimization for font file size. A full Inter variable font weighing 330 KB becomes a 22 KB Latin-only subset -- a 93% reduction. Every kilobyte removed is a kilobyte that does not block text rendering.

glyphhanger is the most developer-friendly subsetting tool. It spiders your pages, collects every character actually used, and generates a minimal subset containing only those glyphs:

Bash -- glyphhanger workflow
# Install glyphhanger (requires Python fonttools)
npm install -g glyphhanger
pip install fonttools brotli

# Spider a local site and subset all fonts used
glyphhanger http://localhost:3000 \
  --subset=./fonts/inter-var.woff2 \
  --formats=woff2

# Or use a predefined Unicode range for Latin
glyphhanger --LATIN \
  --subset=./fonts/inter-var.woff2 \
  --formats=woff2

# Output: inter-var-subset.woff2 (typically 15-30 KB)

pyftsubset from the fonttools Python package gives you manual control over the exact Unicode ranges and OpenType features to retain:

Bash -- pyftsubset for Latin + currency symbols
pip install fonttools brotli

pyftsubset inter-var.ttf \
  --output-file=inter-var-latin.woff2 \
  --flavor=woff2 \
  --layout-features='kern,liga,calt,locl' \
  --unicodes=\
'U+0000-00FF,'\
'U+0131,'\
'U+0152-0153,'\
'U+02BB-02BC,'\
'U+02C6,U+02DA,U+02DC,'\
'U+0304,U+0308,U+0329,'\
'U+2000-206F,'\
'U+2074,U+20AC,'\
'U+2122,U+2191,U+2193,'\
'U+2212,U+2215,'\
'U+FEFF,U+FFFD'

# For Cyrillic support, add: U+0400-045F, U+0490-0491, U+04B0-04B1
# For Vietnamese, add: U+0102-0103, U+0110-0111, U+1EA0-1EF9

unicode-range for dynamic subsetting: If your audience includes multiple scripts, provide separate @font-face rules per script range. The browser downloads only the subsets needed to render the current page's text:

CSS -- Multi-script unicode-range subsetting
/* Latin subset -- downloaded for English/French/German pages */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-latin.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153,
    U+02BB-02BC, U+02C6, U+02DA, U+02DC;
}

/* Cyrillic subset -- downloaded only for Russian/Ukrainian pages */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-cyrillic.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
  unicode-range: U+0400-045F, U+0490-0491,
    U+04B0-04B1, U+2116;
}

/* Greek subset */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-greek.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
  unicode-range: U+0370-03FF;
}

The performance budget tool can help you set per-font file size limits before you begin subsetting, giving you a measurable target.

Font loading strategies

The browser does not start downloading a font file until it has parsed the HTML, downloaded and parsed the CSS, and determined that a text node actually uses that font. This discovery chain means font downloads start very late relative to navigation -- often 300-600 ms after the HTML response begins. Three tools shorten or eliminate this gap.

Preload moves the font download to the highest-priority resource queue, starting it as soon as the HTML is parsed -- before the CSS is even requested. This is the most effective single technique for improving font-related LCP.

HTML -- Preload critical fonts
<head>
  <!-- 1. Preloads first -- highest priority fetch -->
  <link
    rel="preload"
    href="/fonts/inter-var-latin.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />

  <!-- Preload display font used in hero heading (LCP candidate) -->
  <link
    rel="preload"
    href="/fonts/cabinet-grotesk-var.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />

  <!-- 2. Stylesheets after preloads -->
  <link rel="stylesheet" href="/styles/main.css" />
</head>

<!-- Rules of thumb:
     - Preload at most 2 fonts (body + heading)
     - Always include crossorigin (required for fonts)
     - Only preload fonts actually used above the fold
     - Do NOT preload icon fonts or fonts used below fold -->

font-display controls what text does while the font is downloading. The five values produce meaningfully different LCP and CLS tradeoffs:

CSS -- font-display values and when to use each
/* auto: browser default -- often FOIT (invisible text) */
@font-face { font-display: auto; }

/* block: FOIT for up to 3s, then swap. WORST for LCP.
   Use only for icon fonts where fallback glyphs are unusable. */
@font-face { font-display: block; }

/* swap: show fallback immediately, swap when font loads.
   Best for body text and LCP-candidate text.
   Pair with size-adjust to avoid CLS. */
@font-face { font-display: swap; }

/* fallback: 100ms FOIT, then fallback; 3s swap window.
   Good middle ground for display fonts. */
@font-face { font-display: fallback; }

/* optional: 100ms FOIT, no swap after that.
   Best for non-critical decorative fonts.
   Zero CLS, but first-time visitors on slow connections
   may never see the custom font. */
@font-face { font-display: optional; }

/* Recommended combination:
   - Body fonts:       font-display: swap
   - Brand headings:   font-display: swap  (with size-adjust)
   - Decorative/icons: font-display: optional */

The CSS Font Loading API provides programmatic control for advanced scenarios, such as batching multiple font swaps into a single frame to minimize CLS, or deferring non-critical fonts until after the LCP element has painted. See the font-loading CLS guide for the full Font Loading API pattern. For LCP optimization, the most useful property is document.fonts.ready -- a promise that resolves when all fonts referenced by current page CSS have finished loading:

JavaScript -- Defer non-critical fonts until after LCP
// Load body and heading fonts eagerly (preloaded in HTML)
// Defer icon and decorative fonts until page is interactive

document.addEventListener('DOMContentLoaded', () => {
  // Wait for LCP element to be painted before loading secondary fonts
  const lcpObserver = new PerformanceObserver((list) => {
    // LCP has been recorded -- safe to load decorative fonts now
    loadDecorativeFonts();
    lcpObserver.disconnect();
  });

  lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
});

function loadDecorativeFonts() {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = '/fonts/decorative-fonts.css';
  document.head.appendChild(link);
}

For guidance on how fonts interact with render-blocking CSS, see the companion fix on render-blocking CSS and LCP.

Framework tooling

Modern frameworks provide first-class font optimization APIs that automate subsetting, self-hosting, preloading, and fallback metric calculation. Using these beats hand-crafting @font-face rules for most projects.

Next.js (next/font) is the most comprehensive solution. It downloads fonts at build time, self-hosts them on your domain, generates size-adjust fallback @font-face declarations, and injects the correct preload tags automatically:

TypeScript -- next/font with Google Font and local font
// app/layout.tsx
import { Inter } from 'next/font/google';
import localFont from 'next/font/local';

const inter = Inter({
  subsets: ['latin'],       // Only Latin glyphs -- ~22 KB
  display: 'swap',
  variable: '--font-body',
  preload: true,            // Adds link rel=preload automatically
});

const cabinet = localFont({
  src: [
    {
      path: './fonts/cabinet-grotesk-var.woff2',
      weight: '100 900',
    },
  ],
  display: 'swap',
  variable: '--font-display',
  adjustFontFallback: 'Arial', // Auto-calculates size-adjust
});

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

@nuxtjs/fontaine brings the same metric-override capability to Nuxt 3. It reads your existing @font-face declarations and generates fallback rules with correct size-adjust, ascent-override, and descent-override values:

TypeScript -- Nuxt 3 with @nuxtjs/fontaine
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/fontaine'],

  // @nuxtjs/fontaine reads your CSS and generates
  // fallback @font-face rules with correct metric overrides
  // No additional configuration needed for most fonts

  // Optional: override fallback font per family
  fontMetrics: {
    fonts: [
      {
        family: 'Inter',
        src: '/fonts/inter-var-latin.woff2',
        fallbacks: ['Arial'],
      },
    ],
  },
});

Astro with Fontsource lets you install fonts as npm packages and import them with tree-shaking -- only the character subsets and weights you actually import are bundled:

Bash + Astro -- Fontsource font install
# Install font package
npm install @fontsource-variable/inter

# In your Astro layout or component
---
// Only imports the variable font WOFF2, auto-generates @font-face
import '@fontsource-variable/inter';
---

<!-- Or subset to specific weights -->
<style>
  @import '@fontsource/inter/latin-400.css';
  @import '@fontsource/inter/latin-700.css';
</style>

Remix handles fonts through standard HTML -- the links export in route modules. Use it to add preload tags scoped to the routes that actually use a given font:

TypeScript -- Remix font preloading via links export
// app/root.tsx
import type { LinksFunction } from '@remix-run/node';

export const links: LinksFunction = () => [
  // Preload body font (used on every route)
  {
    rel: 'preload',
    href: '/fonts/inter-var-latin.woff2',
    as: 'font',
    type: 'font/woff2',
    crossOrigin: 'anonymous',
  },
  // Load the CSS that declares @font-face
  { rel: 'stylesheet', href: '/styles/fonts.css' },
];

CSS @font-face with size-adjust fallbacks -- for projects without a framework, the manual approach is straightforward. Calculate the size-adjust ratio by comparing your custom font's average character width against the system fallback's. The fontaine npm package automates this calculation as a PostCSS plugin:

CSS -- Manual size-adjust fallback for Inter
/* Step 1: Declare custom font */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var-latin.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
}

/* Step 2: Declare fallback with metric overrides */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.64%;
  ascent-override: 90%;
  descent-override: 22.43%;
  line-gap-override: 0%;
}

/* Step 3: Stack custom font over adjusted fallback */
body {
  font-family: 'Inter', 'Inter Fallback', Arial, sans-serif;
}

Variable fonts and modern browser features

Variable fonts debuted in the OpenType 1.8 specification and are now supported by every modern browser. Beyond weight, a variable font may expose axes for width, slant, optical size, grade, and custom axes defined by the typeface designer. CSS accesses axes through font-variation-settings or the higher-level properties font-weight, font-stretch, and font-style.

CSS -- Variable font axis control
/* Standard axes via high-level properties */
.heading {
  font-weight: 650;        /* Any value 100-900 */
  font-stretch: 87.5%;     /* Width axis */
}

/* Custom axes via font-variation-settings */
/* (use only when no CSS property exists for the axis) */
.optical-large {
  font-variation-settings: 'opsz' 48, 'GRAD' 150;
}

/* Animating weight on hover -- smooth transition possible */
.animated {
  font-weight: 400;
  transition: font-weight 0.2s ease;
}
.animated:hover {
  font-weight: 700;
}

content-visibility: auto tells the browser to skip rendering and layout for off-screen sections entirely. For font loading, this means fonts referenced only in below-the-fold sections are not downloaded until those sections approach the viewport. On content-heavy pages with many unique display fonts per section, this can reduce initial font payload by 50% or more:

CSS -- content-visibility for deferred font loading
/* Apply to below-the-fold sections */
.below-fold-section {
  content-visibility: auto;
  /* contain-intrinsic-size prevents layout shift when
     the section enters the viewport */
  contain-intrinsic-size: 0 800px; /* estimated height */
}

/* Fonts used only inside .below-fold-section are fetched
   lazily as the section approaches the viewport. */

font-synthesis controls whether the browser artificially synthesizes bold or italic variants when a font file for that weight is not available. Synthesized bold looks noticeably worse than a true bold cut. Set font-synthesis: none to prevent the browser from generating fakes, which also prevents unexpected weight rendering while the real font loads:

CSS -- Disable font synthesis
/* Prevent synthesized bold/italic from appearing
   during font load or when a weight is missing */
body {
  font-synthesis: none;
}

/* Or be specific about which synthesis to block */
body {
  font-synthesis: weight;  /* No fake bold */
  font-synthesis: style;   /* No fake italic */
}

CSS @font-palette-values is a new at-rule for theming color fonts. If you use a COLRv1 font (such as color emoji or branded icon fonts), @font-palette-values lets you override specific palette entries without creating a new font file. This is relevant for performance because it replaces the older pattern of shipping multiple color-variant font files:

CSS -- @font-palette-values for color font theming
/* Override color entries in a COLRv1 font */
@font-palette-values --brand-palette {
  font-family: 'Bungee Color';
  override-colors:
    0 #0047AB,  /* Primary brand blue */
    1 #FFD700;  /* Accent gold */
}

.logo-text {
  font-family: 'Bungee Color';
  font-palette: --brand-palette;
}

Step-by-step: full font optimization workflow

Step 1: Audit current font payload

Open Chrome DevTools, go to the Network panel, and set the filter to "Font". Record a cold load (Ctrl+Shift+R or disable cache in DevTools settings). Note: total KB of fonts transferred, the number of individual font requests, the timing of the first font request relative to navigation start, and whether any fonts appear in the "Render Blocking" list in the Performance panel. Repeat the test using the Lighthouse audit to get the "Preload key requests" and "Reduce unused CSS" opportunities that reference fonts. A well-optimized page has 1-2 font requests, each under 30 KB, with the first request starting within 200 ms of navigation.

DevTools -- Font audit checklist
# DevTools Network > Font filter: look for
# - Total font bytes transferred
# - Number of font requests
# - "Waterfall" column: when does first font request start?

# DevTools Performance panel > Bottom-up tab
# Search "font" -- identifies render-blocking font requests

# Lighthouse CLI audit
npx lighthouse https://example.com \
  --only-audits=font-display,uses-optimized-images \
  --output=json | jq '.audits["font-display"]'

Step 2: Self-host and subset fonts to your primary script

Download font files (Google Fonts offers a direct download; Fontshare provides WOFF2 downloads). Run glyphhanger or pyftsubset to strip all glyphs outside your users' primary script. For most Western-language sites, Latin-only subsetting is correct. If you serve audiences in Russia or Eastern Europe, include Cyrillic. For pan-Asian sites, use multiple @font-face rules with per-script unicode-range so each script's file is only downloaded on pages that need it.

Bash -- Download and subset a Google Font
# 1. Download the font files (use google-webfonts-helper)
#    https://gwfh.mranftl.com/fonts -- select WOFF2 only

# 2. Install fonttools
pip install fonttools brotli

# 3. Subset to Latin
pyftsubset "Inter[slnt,wght].ttf" \
  --output-file="inter-var-latin.woff2" \
  --flavor=woff2 \
  --layout-features='kern,liga,calt' \
  --unicodes="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"

# Result: ~22 KB vs ~330 KB for full font file

Step 3: Convert to WOFF2 and enable Brotli

If your font files are not already WOFF2, convert them with fonttools. Then configure your web server or CDN to serve all font files with Brotli compression and a long-lived immutable cache header. WOFF2 uses internal Brotli compression, but your web server's transport-layer Brotli compression adds an additional 5-10% size reduction on the WOFF2 container itself. The immutable cache directive prevents the browser from revalidating the font on return visits, eliminating a network round-trip.

Bash + Nginx -- Convert and configure caching
# Convert TTF to WOFF2
python -c "
from fontTools.ttLib import TTFont
font = TTFont('inter-var.ttf')
font.flavor = 'woff2'
font.save('inter-var.woff2')
"

# Nginx: long-lived font caching
location ~* \.(woff2|woff|ttf)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "*";
    brotli on;
    brotli_types font/woff2;
    gzip on;
    gzip_types font/woff2;
}

# Vercel (vercel.json)
{
  "headers": [
    {
      "source": "/fonts/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
      ]
    }
  ]
}

Step 4: Preload above-the-fold fonts with link rel=preload

Add a <link rel="preload"> tag for each font file used by text in the LCP candidate region (typically the hero headline and first paragraph). Place these preload tags as early as possible in the <head> -- before any stylesheets. Limit to 1-2 files; preloading more fonts competes with other critical resources. The crossorigin attribute is mandatory even when fonts are self-hosted on the same origin, because font requests use CORS headers.

HTML -- Font preload placement
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- Preloads FIRST -- before any other resource hints or stylesheets -->
  <link
    rel="preload"
    href="/fonts/inter-var-latin.woff2"
    as="font"
    type="font/woff2"
    crossorigin
  />

  <!-- Then the stylesheet that declares @font-face -->
  <link rel="stylesheet" href="/styles/main.css">

  <!-- title, other meta, etc -->
</head>

Step 5: Use a framework font module for automatic size-adjust fallback

If you are using Next.js, Nuxt, Astro, or Remix, switch to the framework's font module. These modules handle self-hosting, subsetting, preloading, and fallback metric calculation automatically -- eliminating the most error-prone parts of manual font optimization. For vanilla HTML/CSS projects, use the fontaine PostCSS plugin to auto-generate fallback @font-face rules, or calculate the size-adjust value manually using the Font Style Matcher tool and add it to your @font-face declarations. Consult the FAQ for more guidance on choosing the right approach for your stack.

Bash -- fontaine PostCSS plugin (vanilla projects)
# Install fontaine as PostCSS plugin
npm install -D fontaine

# postcss.config.js
module.exports = {
  plugins: {
    fontaine: {
      // fontaine reads your CSS, finds @font-face rules,
      // and injects fallback @font-face with size-adjust
      // calculated from the actual font metrics
      fallbacks: ['BlinkMacSystemFont', 'Segoe UI', 'Arial'],
    },
  },
};

Common mistakes

  • Preloading too many fonts. Preloading more than 2 fonts steals bandwidth from the HTML, CSS, and LCP image. Only preload fonts whose text appears above the fold on first paint.
  • Omitting crossorigin on preload tags. Without crossorigin, the browser makes a second font request at parse time because the preloaded resource and the font CSS request use different CORS modes. You end up with two requests instead of one.
  • Shipping full, unsubsetted font files. The most common mistake. A 300 KB font file where only 5% of the glyphs are used in your content is 285 KB of pure waste. Always subset before deploying.
  • Using font-display: block on body fonts. Block mode hides text for up to 3 seconds while the font loads. This directly tanks LCP. Only use block for icon fonts where a fallback glyph would render an unrelated character.
  • Not setting cache headers for self-hosted fonts. Without long-lived cache headers, the browser revalidates every font file on every page load. Set Cache-Control: public, max-age=31536000, immutable on all font files and use content-hashed filenames so the cache can be busted when fonts change.

Frequently asked questions

Variable fonts are almost always the better choice for performance. A single variable font file replaces four or more static weight files, reducing HTTP requests and simplifying preloading. The one file is typically 20-40% larger than a single static weight, but far smaller than the combined weight of all static variants. Use variable fonts unless you only need one or two weights and your audience is on older browsers. One preload tag covers all weights, which is a significant simplification for LCP optimization.

Yes, for two reasons. First, loading fonts from fonts.googleapis.com requires a DNS lookup, TCP handshake, and TLS negotiation -- typically 100-300 ms on a cold connection before a single byte of font data is transferred. Second, you cannot preload Google Fonts files because the final font URL is only known after the CSS from googleapis.com is parsed. Self-hosting eliminates both issues. Additionally, Google Fonts raises GDPR compliance concerns for European audiences. If you must use Google Fonts, add preconnect hints for both fonts.googleapis.com and fonts.gstatic.com to reduce the latency penalty.

Font subsetting removes glyph data for characters your site will never display. A full-coverage font file supporting Latin, Cyrillic, Greek, Vietnamese, and all OpenType features may weigh 250-500 KB per weight. Subsetting to Latin only -- covering English, French, German, Spanish, and most Western European languages -- typically yields 15-30 KB per file: an 80-90% reduction. Tools include glyphhanger (Node, analyzes your actual HTML for used characters), pyftsubset from fonttools (Python, unicode-range based), and the Google Fonts CSS API (automatic subsetting via unicode-range). The performance budget tool can help you define per-font size targets.

Layout shift during a font swap (FOUT -- Flash of Unstyled Text) happens because the custom font and the fallback font have different metrics: different average character widths, ascent heights, descent depths, and line spacing. When the browser swaps from one to the other, every text node on screen is reflowed and the displacement registers as CLS. The fix is to add size-adjust, ascent-override, descent-override, and line-gap-override to a @font-face rule targeting your fallback font so it matches the custom font's measurements. The companion guide on font-loading CLS covers the full metric-override approach with exact values for popular fonts.

Wrap below-the-fold sections in an element with content-visibility: auto. The browser skips rendering off-screen content, which means it also defers font downloads for text inside those sections until they approach the viewport. This is especially effective for pages with many unique display fonts used only in individual sections -- each font is fetched on demand rather than at page load. Always pair content-visibility with contain-intrinsic-size to give the browser an estimated height so the scrollbar does not jump when sections are rendered. Current browser support is Chrome 85+, Edge 85+, and Opera 71+. Safari added support in version 18.

Priya Patel

Web Performance Researcher

Priya researches Core Web Vitals optimization patterns across large-scale web properties. Her work focuses on font delivery, resource loading order, and the intersection of design systems and performance budgets.

Related fixes