Web fonts and page speed: load one face, and load it first

You want clear type without slowing the first paint or nudging content. This guide gives you one fast way to load fonts and the trade-offs for small sites. You will ship a stable hero, a readable page and a pass on Core Web Vitals, without a week in type theory.

By , founder of Porteur · Updated 14 September 2026 · Markdown

Why fonts delay paint and cause shifts

A web font is render blocking the moment it sits in your first text paint. The browser must discover the font in CSS, request it, and wait. If you block render on that file, First Contentful Paint slips. If you do not, the browser draws with a fallback then swaps later. That swap can move lines and trigger Cumulative Layout Shift.

On a small product site, the hero is often the Largest Contentful Paint: a big heading or a poster image. If the hero text uses a late font, LCP waits. If the font swaps in with different metrics, CLS grows. Your aim is simple: one font face, discovered early, loaded early, and a fallback that matches its metrics so the swap does not move anything.

Pick a path: system stack, one self hosted variable WOFF2, or a host

Decide first. Fonts are a product choice, not just a build detail. Here is the order, best first for speed and simplicity.

PathWhat it isWhen to chooseSpeed impactNotes
System font stackUse platform default faces like -apple-system, Segoe UI, RobotoYou want zero font bytes and instant paint, brand is fine with native lookFastestNo downloads, no CLS from swaps, consistent with apps
One self-hosted variable WOFF2One variable font file on your domain, WOFF2, subsets per rangeYou need a brand face and a spread of weightsVery fast when preloadedOne DNS, one TLS, HTTP/2 reuse, replaces multiple files
Third-party hostLoad from a font CDN like Google Fonts or a foundry CDNYou cannot self-host due to licence or policySlowerExtra DNS, connection and TLS before bytes, control is limited

For most small sites, the sweet spot is one self-hosted variable WOFF2 plus a clean system fallback stack. You keep brand control, ship one file, and avoid a network hop to a third-party origin.

font-display: swap or optional

Your font can block render, or it can let the fallback draw and swap later. You control this with font-display. For product pages, pick between swap and optional.

  • swap: the browser shows fallback immediately, then swaps to the web font when it arrives. Good for readable first paint and steady UX when your fallback metrics match.
  • optional: the browser may never swap if the font is late or the connection is slow. Good for speed first, minor visual variance on slow links.
  • avoid auto or block for above-the-fold text: they can delay first paint or cause a long flash of invisible text.
@font-face {
  font-family: "AcmeVar";
  src: url("/fonts/AcmeVar.woff2") format("woff2-variations");
  font-display: swap; /* or optional */
  /* font-weight range declared if needed by your setup */
  font-style: normal;
}

body { font-family: "AcmeVar", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }

Preload the one face above the fold

Preload tells the browser about a needed resource early. Do this for the one face used in your first viewport. It cuts discovery time. It helps LCP when the hero is text. Only preload what is definitely used on the first paint, or you waste priority.

<!-- In <head> before your CSS link -->
<link rel="preload" as="font" href="/fonts/AcmeVar.woff2" type="font/woff2" crossorigin>
<link rel="preconnect" href="https://yourproduct.com" crossorigin> <!-- usually implicit for same-origin -->

If your first text sits inside a hero that uses a specific axis value, specify that weight in your CSS so the browser knows it will use the same file. You do not need separate files for each weight when you use a variable WOFF2.

Subset with unicode-range to ship fewer bytes

Most fonts carry far more glyphs than your site needs. You can split the file per script and load less on first paint. Use unicode-range with a subset file for Latin, and keep extended sets lazy. This cuts transfer and parse cost.

/* Latin subset for above-the-fold */
@font-face {
  font-family: "AcmeVar";
  src: url("/fonts/AcmeVarLatin.woff2") format("woff2-variations");
  font-display: swap;
  /* declare weight range if your build needs it */
  font-style: normal;
  unicode-range: U+0-7F; /* Basic Latin */
}

/* Extended subset loaded when needed */
@font-face {
  font-family: "AcmeVar";
  src: url("/fonts/AcmeVarExt.woff2") format("woff2-variations");
  font-display: optional;
  /* declare weight range if your build needs it */
  font-style: normal;
  unicode-range: U+0100-017F, U+0180-024F; /* example ranges only */
}

Generate subsets with your type vendor or a font tool. Keep one Latin subset for your default language, measured on real copy. Avoid creating many tiny files that fight for priority. One small subset for first paint is enough on most sites.

Match fallback metrics to stop the swap from moving text

When you show fallback text then swap, the line height and glyph width must match. If not, the swap can push content and raise CLS. Fix this with CSS font metric overrides on the fallback. You set ascent, descent, line gap and size adjustment so the layout stays the same after the swap.

/* 1. Choose a close fallback face */
body { font-family: "AcmeVar", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }

/* 2. Override its metrics to match AcmeVar */
@font-face {
  font-family: "FallbackMetrics";
  src: local("Arial");
  size-adjust: 1;           /* example only, set from your font */
  ascent-override: 0.9;     /* example only, set from your font */
  descent-override: 0.25;   /* example only, set from your font */
  line-gap-override: 0;     /* example only, set from your font */
}

/* 3. Use the adjusted fallback ahead of system stack */
body { font-family: "AcmeVar", "FallbackMetrics", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }

You get the target metrics from the font vendor or a tool. Test the swap on a throttled link and watch for movement. Your fixed page shows no jump when the face arrives, and your CLS stays under 0.1 across sessions.

Worked example: from two slow faces to one fast face

Say your homepage at yourproduct.com uses two families: FancySans regular and bold from a third-party CDN, plus FancySerif regular for quotes. The CSS pulls three separate WOFF files with no preload. The hero H1 is the LCP and uses FancySans bold. Lighthouse on Mobile shows a late first paint and a layout jump in the hero. The field report in Search Console shows LCP above 2.5 s at the 75th percentile and CLS at 0.18.

  1. Pick the path

    Drop FancySerif. Replace FancySans with one self-hosted variable WOFF2, AcmeVar, covering light through bold.

  2. Host and subset

    Place /fonts/AcmeVarLatin.woff2 on your origin. Generate a Latin subset for first paint. Keep the full set for later.

  3. Declare with swap and unicode-range

    Define @font-face for the Latin subset with font-display: swap and a unicode-range for Latin.

  4. Preload the face

    Add <link rel="preload" as="font" href="/fonts/AcmeVarLatin.woff2" type="font/woff2" crossorigin> to <head> before CSS.

  5. Set the stack and weight

    Use body { font-family: "AcmeVar", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } and H1 { font-weight: bold; }.

  6. Match fallback metrics

    Add a metrics-override fallback based on Arial. Adjust size and metrics to match AcmeVar headings.

  7. Defer the rest

    Load the extended glyph set with font-display: optional. Do not preload it. Only faces used below the fold load later.

<!-- Before: third-party, multiple files, no preload -->
<link href="https://fonts.examplecdn.com/css2?family=FancySans&display=swap" rel="stylesheet">
<link href="https://fonts.examplecdn.com/css2?family=FancySerif&display=swap" rel="stylesheet">

/* After: one self-hosted variable WOFF2 */
<link rel="preload" as="font" href="/fonts/AcmeVarLatin.woff2" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/css/site.css">

Result on a mid-range phone: the hero paints with fallback immediately, then swaps with no movement. LCP improves because the face is discovered early and comes from your origin. CLS drops below 0.1 because the fallback metrics match. Lighthouse’s lab score rises, and the Core Web Vitals field assessment trends to pass after 28 days when 75th percentile LCP sits at or under 2.5 s. Scores vary between runs, so read the field section first in PageSpeed Insights and the Core Web Vitals report in Search Console.

Implementation details that save seconds

  • Keep @import out of your CSS for fonts. Put @font-face in your main CSS and preload the file.
  • Inline critical CSS for the above-the-fold layout. Fonts still need a separate request, so preload the file too.
  • Do not lazy-load fonts. Load one face early. Load the second face only when content that uses it enters the viewport or after load.
  • Avoid data URIs for fonts. They block CSS delivery and cannot be cached across pages as well as a file.
  • Minify and cache. Serve WOFF2 with long cache-control and immutable. Bump the file name on change, for example AcmeVarLatin.hash.woff2.
  • Use HTTP/2 or HTTP/3. Your CSS and font share the same connection and arrive faster from your origin.
  • Use type="module" and defer scripts. Keep the main thread free so the swap paints fast and INP stays good.

How this ties to Core Web Vitals and Lighthouse

Fonts touch all three Core Web Vitals. LCP waits if your hero text uses a font discovered late or fetched from a slow origin. CLS rises when a swap changes metrics. INP suffers if render work blocks the main thread during the swap or hydration. Aim for LCP at or under 2.5 s, CLS at or under 0.1, and INP at or under 200 ms at the 75th percentile of real users over 28 days.

Use PageSpeed Insights to see both the field data and a lab run. The top section shows the Chrome User Experience Report data when there is enough traffic. That is what Search uses. The lower section is Lighthouse on a simulated mid-range phone over throttled 4G. The two can disagree. Field data wins. Lighthouse’s score weights Total Blocking Time 30 percent, LCP 25 percent and CLS 25 percent as of version 10 and later. Use the audits to spot render-blocking CSS, late preloads and third-party fonts.

Check Search Console’s Core Web Vitals report for your mobile URLs. It shows pass or fail at the Good thresholds on field data. Fixes take time to reflect. The report aggregates over 28 days and at the 75th percentile. A URL with too few visits falls back to origin-level data or shows nothing. Keep shipping improvements, then wait for the trend to settle.

Third-party fonts and when you cannot self-host

Sometimes a licence or a brand team rules out self-hosting. You can still reduce delay. Preconnect to the font CDN. Use display=swap if the service allows it. Inline only the small CSS that declares the font. Avoid CSS that pulls many unused weights. If the host lets you subset, pick the basic Latin set for first paint and keep extended sets separate.

<!-- Preconnect to third-party font host in <head> -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Acme&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Acme&display=swap" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Acme&display=swap"></noscript>

You still pay an extra DNS lookup, a new connection and a TLS handshake. That is why a self-hosted WOFF2 is faster. If you must use a host, keep the rest of the page light so the connection cost is hidden by other work. Match fallback metrics the same way to avoid CLS when the swap lands.

Checklist: one fast font load on a product site

  • Decide: system stack, or one self-hosted variable WOFF2, or third-party.
  • Keep one face above the fold. Remove decorative faces from the hero.
  • Self-host WOFF2 on your origin. Serve with long caching.
  • Subset for Latin with unicode-range. Keep extended sets optional.
  • Set font-display: swap for the above-the-fold face. optional for non-critical sets.
  • Preload the above-the-fold WOFF2 with as=font and crossorigin.
  • Match fallback metrics with size-adjust and ascent-override.
  • Keep CSS critical inline. Defer scripts. Avoid @import for fonts.
  • Verify in PageSpeed Insights: field data section trends to pass, lab run shows no render-blocking fonts.
  • Watch Search Console Core Web Vitals over 28 days. Adjust if CLS or LCP slips.

Questions

Sources

Check my site, free

Want to see if your hero font is blocking paint or shifting layout? Paste your homepage URL and get three concrete findings in about thirty seconds.

  • Free check, no card
  • Read-only, your own accounts
  • Readable by your agent

Read next