Bcorre

Web Fonts and Performance: How Not to Kill LCP with a Custom Font

Custom fonts are the leading cause of CLS and LCP failures. We break down font-display strategies, preload, self-hosting, and the Font Loading API for premium typography without the performance cost.

Every designer wants premium typography. Every developer plugs in Google Fonts via <link>. And so your site loads great — images, JS, CSS all fly in — yet the user stares at a white screen for another two seconds waiting for the font. LCP is in the red zone, Core Web Vitals are blown, rankings have dropped.

In this article — how to use custom fonts without killing performance.

How the browser handles fonts

The standard font loading sequence:

  1. The browser parses HTML, builds the DOM
  2. Parses CSS, finds @font-face or <link rel="stylesheet"> referencing the font
  3. Only now requests the font file
  4. Downloads the font (typically 50–200 KB)
  5. Parses the font, renders text

Time passes between steps 1 and 5. On a fast connection — 200–500 ms; on a slow one — 2–4 seconds. Throughout that window your text is either invisible (FOIT — Flash Of Invisible Text) or rendered in a system fallback and then "jumps" to the custom font (FOUT — Flash Of Unstyled Text).

FOIT kills LCP — no text means no LCP candidate. FOUT kills CLS — text "jumps" because the fallback and the custom font have different metrics.

Strategy 1: font-display

The CSS property font-display controls behaviour during loading. Values:

  • auto — the browser decides, usually FOIT
  • block — invisible text until the font loads (3 sec), then swap
  • swap — immediate system fallback, then swap to the custom font (causes CLS)
  • fallback — a compromise: 100 ms of invisibility, then swap, then fix after 3 sec
  • optional — the most conservative: 100 ms of invisibility, then permanent fallback (if the font didn't arrive in time)

Recommendation: swap for most sites (best UX); optional if CLS is critical and it is acceptable to occasionally skip the custom font.

@font-face {
  font-family: 'General Sans';
  src: url('/fonts/general-sans-medium.woff2') format('woff2');
  font-weight: 500;
  font-display: swap;
}

Strategy 2: preload critical fonts

font-display: swap solves FOIT, but FOUT remains — the user first sees the system font, then the replacement. On a main hero block this is jarring.

The solution is to preload critical fonts:

<head>
  <link rel="preload"
        href="/fonts/general-sans-medium.woff2"
        as="font"
        type="font/woff2"
        crossorigin>
</head>

What this does: the browser starts downloading the font immediately after HTML, without waiting for CSS to be parsed. The font typically arrives by the time text needs to be rendered — FOUT does not occur.

Important: the crossorigin attribute is required on the preload even for self-hosted fonts. Without it the browser will make a second request for the font, making the preload useless.

What to preload:

  • All weights used above the fold (typically 400 + 600, or 500 + 700)
  • Do not preload every variant — this pollutes network priority

Strategy 3: self-hosting instead of Google Fonts

Google Fonts via <link> has three drawbacks:

  1. An extra DNS lookup to fonts.googleapis.com, then to fonts.gstatic.com — 2 connection setups
  2. Dependency on an external service — if Google Fonts goes down (it has happened), your font goes down with it
  3. GDPR risk — Google Fonts may log user IP addresses, which constitutes personal data

Self-hosting (putting font files on your own server) solves all of this:

# Download files from https://gwfh.mranftl.com/fonts (Google Fonts Helper)
# Place them in public/fonts/

In CSS:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-variable.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}

Add HTTP/2 from your own domain — one TCP connection, all fonts over a single channel.

Strategy 4: variable fonts

A custom font typically ships as many files — one per weight per style. 12 files = 12 requests = 12 downloads.

Variable fonts are a single file with all weights encoded inside. The browser interpolates between them. One 80 KB file instead of 12 files at 30 KB each — savings in both traffic and request count.

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-variable.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-style: normal;
}

/* Usage */
h1 { font-weight: 700; }
p { font-weight: 400; }
.lead { font-weight: 500; }

Variable font support — all modern browsers. Available since 2018.

Strategy 5: matching the fallback to the custom font

FOUT (the font jump) can be minimised if the system fallback has similar proportions to the custom font.

CSS properties for this: size-adjust, ascent-override, descent-override, line-gap-override inside @font-face.

@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 107.4%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter-fallback', system-ui, sans-serif;
}

Effect: while Inter is loading, text is rendered using Arial with adjusted proportions — it looks almost identical to Inter. When the real Inter loads, the swap happens with no visible jump.

Utility for calculating overrides: Fontkit or the Capsize CSS generator.

Strategy 6: subsetting

A full font file contains every character — Latin, Cyrillic, Greek, Asian glyphs, mathematical symbols, emoji. If your site is in English, you only need Latin plus basic symbols. This can shrink the file from 200 KB down to 60–80 KB.

# Using pyftsubset (Python):
pyftsubset Inter-Regular.ttf \
  --unicodes="U+0020-007E,U+0400-04FF,U+0500-052F,U+2000-206F,U+2070-209F,U+20A0-20CF" \
  --output-file=Inter-Regular-subset.woff2 \
  --flavor=woff2

Or via the Google Fonts API, which already returns a subset:

@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap&subset=cyrillic-ext');

What not to do

  • Do not load 10 weights if you use only 3. Each weight adds +30–50 KB.
  • Do not load custom fonts for print stylesheets or email. It is wasted overhead.
  • Do not use the FontFace JS API unless you have a specific reason. In most cases font-display: swap plus preload handles the task declaratively.

A real-world example

Before optimisation:

  • Google Fonts via <link>, 4 weights
  • LCP 3.8 sec on 4G mobile
  • CLS 0.15 due to FOUT

After:

  • Self-hosted Inter Variable woff2 (78 KB), single file
  • Preload of one variant
  • size-adjust overrides for the fallback
  • LCP 1.9 sec
  • CLS 0.04

Fonts alone delivered −1.9 sec off LCP. No other changes.

Summary

Fonts are the invisible part of a site that can kill performance. The simple rules: self-hosting, variable fonts, preload + font-display: swap, subsetting, fallback with overrides. Implementable in a few hours of work.

If you have a site with custom typography and LCP issues — get in touch. Free performance audit within 2 business days.

Сайт + SEO + GEO/AEO

This is part of our service Видимость под ключ

Сайт, SEO, GEO/AEO, хостинг и аналитика в одной подписке

Go to service →
Take it further?

Need an expert eye on your project?

We do an express audit in 2 business days: showing where your site is losing traffic and what to fix first.

Discuss project