Self-Hosted Fonts in 2026: Subsetting, WOFF2, font-display, and Preconnect Done Right

Google Fonts is a third-party DNS lookup, a TCP handshake, a TLS negotiation, and a cross-origin request — all before your font bytes start moving. In 2026, with GDPR enforcement maturing and Core Web Vitals baked into ranking signals, shipping fonts from fonts.googleapis.com is a quiet performance and privacy tax most sites are still silently paying.

Self-hosting fonts is not complicated. But it has about five distinct places where people get it wrong, and those mistakes either bloat your pages or introduce a flash of invisible text that tanks your LCP score. This article walks through the whole pipeline end-to-end: converting, subsetting, serving with correct headers, and wiring up CSS @font-face with a loading strategy that doesn’t make users stare at blank text.


Why the Old "Just Use Google Fonts" Advice Is Stale

Google Fonts got a significant caching update around 2022 that killed the "shared cache" argument entirely. Browsers no longer share cached resources across origins, so the idea that your visitors already have Roboto cached because they visited Gmail is dead. You were getting all the downsides (external dependency, privacy leak, DNS overhead) with none of the upside.

Beyond caching, there’s the GDPR angle. German and Austrian courts have repeatedly ruled that embedding Google Fonts counts as an unlawful transfer of IP addresses to a US-based third party without consent. If your site has European visitors — and it does — self-hosting is the legally clean path.

Performance-wise, the math is simple: one fewer origin to connect to, full control over Cache-Control headers, and no JavaScript execution or font API calls. Your fonts live on your CDN or server, served with Cache-Control: max-age=31536000, immutable, and the browser never has to ask again.


The Only Format You Need: WOFF2

Forget EOT, TTF, SVG fonts, and even plain WOFF. WOFF2 uses Brotli compression and achieves 30–50% smaller files than WOFF for typical Latin fonts. Browser support for WOFF2 is essentially universal — every browser released in the last six years supports it. If you’re still shipping WOFF as a fallback, you’re shipping dead code.

Your @font-face block in 2026 looks like this:

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/inter-v4-latin-regular.woff2') format('woff2');
}

One format. One file. Done. No format('truetype') fallback, no WOFF. If a browser is old enough that it doesn’t support WOFF2, it falls back to the system font stack anyway — which is exactly what you want.


Subsetting: The Step Most Guides Skip

This is where most self-hosting setups leave serious performance on the table.

A typical variable font like Inter Regular is ~400KB as a TTF. As WOFF2 covering all Unicode ranges, it’s still ~160KB. If your site is English-only, you’re shipping glyphs for Cyrillic, Greek, Vietnamese, and hundreds of symbols your users will never see.

Subsetting strips out the glyphs you don’t need. For a Latin-only site, a properly subsetted Inter Regular can drop to 20–30KB as WOFF2. That’s a 5–8x reduction.

Tool: pyftsubset (from fonttools)

pip install fonttools brotli

brotli is required for WOFF2 output. Without it, pyftsubset silently falls back to zlib.

Basic usage — subset to Latin + Latin Extended + common punctuation:

pyftsubset Inter-Regular.ttf \
  --output-file=inter-v4-latin-regular.woff2 \
  --flavor=woff2 \
  --layout-features="*" \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"

--layout-features="*" keeps all OpenType features (kerning, ligatures, etc.). Dropping this flag will make your font look wrong on some rendering engines — don’t omit it to save a few KB.

The unicode range above is what Google Fonts uses for their latin subset. It covers standard Latin, common punctuation, the Euro sign, and a handful of useful symbols. For most Western European projects this is exactly right.

If you need multiple languages, run pyftsubset multiple times with different unicode ranges and serve the appropriate file via @font-face unicode-range descriptors (more on that below).

Automating the Subset Pipeline

Don’t do this by hand for every font variant. A simple shell script keeps it reproducible:

#!/usr/bin/env bash
# subset-fonts.sh
# Usage: ./subset-fonts.sh Inter-Regular.ttf Inter-Bold.ttf

set -euo pipefail

LATIN_RANGE="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD"
OUTPUT_DIR="./public/fonts"

mkdir -p "$OUTPUT_DIR"

for ttf_file in "$@"; do
  basename=$(basename "$ttf_file" .ttf)
  # snake_case to kebab-case output name
  output_name=$(echo "$basename" | tr '[:upper:]' '[:lower:]' | tr '_' '-')

  echo "Subsetting $ttf_file → ${output_name}-latin.woff2"

  pyftsubset "$ttf_file" \
    --output-file="${OUTPUT_DIR}/${output_name}-latin.woff2" \
    --flavor=woff2 \
    --layout-features="*" \
    --unicodes="$LATIN_RANGE"
done

echo "Done. Files in $OUTPUT_DIR:"
ls -lh "$OUTPUT_DIR"

Run it once per font update. Commit the WOFF2 files to your repo or artifact store — never regenerate them at build time in production.


font-display: Picking the Right Strategy

font-display controls what the browser does while your font is loading. The four values people actually use:

auto — browser decides. In practice this means FOIT (Flash of Invisible Text) in most browsers. Avoid.

block — short block period (usually 3s), then swap. You get invisible text for up to 3 seconds. Only useful if your fallback font is so different from the web font that any flash looks broken. Rare legitimate use case.

swap — zero block period. Browser renders immediately with fallback, swaps to your font when it arrives. Fast perceived load, but you get layout shift (CLS hit) when the swap happens if your fallback and web font have different metrics.

optional — zero block period, very short swap period (~100ms). If the font doesn’t arrive in time, the browser uses the fallback for the rest of the page load and doesn’t swap. On a fast connection, the font loads from cache on repeat visits and it’s seamless. This is the best choice for body text on a well-optimized setup.

fallback — very short block period (100ms), 3 second swap window. A middle ground between swap and optional.

My recommendation: use optional for body text when you’ve properly preloaded your font, and swap for display headings where the visual identity matters more than CLS.

/* Heading font — brand identity matters, accept the swap */
@font-face {
  font-family: 'CalSans';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/fonts/calsans-semibold-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
                 U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC,
                 U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Body font — stability over brand, use optional + preload */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: optional;
  src: url('/fonts/inter-regular-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
                 U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC,
                 U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Always include the unicode-range descriptor. It tells the browser when to bother downloading the font at all — if the page has no characters in that range, the download is skipped entirely.


Preconnect Ordering and Preload: Where Everyone Gets It Wrong

Self-Hosted Fonts Don’t Need Preconnect

<link rel="preconnect"> is for external origins. If you’re self-hosting, the font is on your own domain — there’s nothing to preconnect to. Skip it. I’ve seen tutorials with <link rel="preconnect" href="https://yourdomain.com"> — that does nothing.

What you want instead is <link rel="preload">:

<!-- In your <head>, before your stylesheet link -->
<link
  rel="preload"
  href="/fonts/inter-regular-latin.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
<link rel="stylesheet" href="/css/main.css" />

The crossorigin attribute is not optional. Fonts are always fetched in CORS mode, even from the same origin. Without crossorigin, the browser fetches the font twice: once for the preload hint (anonymous mode) and once when it actually needs it (CORS mode). The cache keys don’t match. You burn the preload entirely.

Ordering Matters

The preload hint must appear before your stylesheet in the <head>. If the stylesheet comes first, the browser has to parse it before discovering the font URL, and by then the preload advantage is gone. The order:

  1. <meta charset> and viewport
  2. Preload hints for critical fonts
  3. Stylesheet links
  4. Everything else
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />

  <!-- Preload only the fonts used above the fold -->
  <link rel="preload" href="/fonts/calsans-semibold-latin.woff2" as="font" type="font/woff2" crossorigin />
  <link rel="preload" href="/fonts/inter-regular-latin.woff2" as="font" type="font/woff2" crossorigin />

  <link rel="stylesheet" href="/css/main.css" />
</head>

Don’t preload every font variant. Preloading signals "this is critical, fetch it immediately." If you preload Inter Bold, Inter Italic, and Inter Regular but only the regular weight appears above the fold, you’ve just queued three downloads that compete with each other and your hero image. Preload one or two fonts maximum — the ones that affect first paint.


Serving Fonts from Nginx: The Headers That Actually Matter

Self-hosting fonts without proper caching headers is like locking your front door but leaving the window open. Fonts are static, versioned assets — they should be cached forever and invalidated by changing the filename.

# nginx.conf snippet
location ~* \.(woff2|woff|ttf)$ {
    # Immutable: tells the browser not to revalidate during the max-age window
    add_header Cache-Control "public, max-age=31536000, immutable";

    # Required for cross-origin font requests (even same-origin via CDN edge)
    add_header Access-Control-Allow-Origin "*";

    # Correct MIME type — some older servers default to octet-stream
    types {
        font/woff2 woff2;
        font/woff  woff;
    }

    # No logging for font assets — just noise
    access_log off;
}

If you’re behind a CDN (Cloudflare, Bunny, Fastly), verify the CDN is actually respecting and forwarding these headers — some configurations strip Cache-Control headers from origin responses.


Gotchas

Gotcha #1: Variable fonts and subsetting

Variable fonts (single file covering multiple weights via OpenType axes) interact badly with naive subsetting. pyftsubset can subset them, but if you pass --layout-features="*" on a variable font you may get a warning about incompatible tables. Test the output by loading it in a browser and exercising all the weights you use. The safest approach: check whether the font is variable (fonttools can tell you: fonttools varLib.instancer --help) and use instancer to pin axes before subsetting if you don’t need the full range.

Gotcha #2: font-display: optional and font not loading on first visit

optional means "if the font isn’t in cache, don’t wait for it." On the absolute first visit to your site, the font might not render even if it’s preloaded, because optional caps the swap window so aggressively. This is by design. On second visit, the font is cached and renders immediately. If this bothers you, use swap — but accept the CLS tradeoff.

Gotcha #3: CORS errors on the font in browser DevTools

You added crossorigin to the preload but not to the @font-face — or vice versa. Both sides need to match. For self-hosted same-origin fonts, crossorigin on the preload link is sufficient; you don’t need crossorigin in CSS. But if the font URL is on a different origin (CDN subdomain, separate domain), the Access-Control-Allow-Origin response header on the font server must match.

Gotcha #4: Lighthouse shows "Eliminate render-blocking resources" pointing at your font CSS

This means your @font-face declarations are in a stylesheet that the browser is waiting on before it can paint. Either inline your @font-face declarations in a <style> block in <head> (for a handful of declarations this is fine), or ensure the stylesheet is loaded asynchronously. For critical above-the-fold fonts, inlining the @font-face block is the cleanest solution.

Gotcha #5: pyftsubset strips the font name

Subsetted fonts sometimes lose their internal name table entries, which can cause issues with some rendering engines or font management tools. Add --name-IDs="*" to preserve all name records:

pyftsubset Inter-Regular.ttf \
  --output-file=inter-regular-latin.woff2 \
  --flavor=woff2 \
  --layout-features="*" \
  --name-IDs="*" \
  --unicodes="..."

Production-Ready Checklist

Before shipping:

  • WOFF2 only, no TTF/WOFF fallback in @font-face
  • Subsetted to actual unicode ranges used on the site
  • --layout-features="*" and --name-IDs="*" in pyftsubset call
  • unicode-range descriptor in every @font-face block
  • font-display: optional for body text, swap for display/brand fonts
  • <link rel="preload"> before stylesheets, only for above-the-fold fonts
  • crossorigin attribute on every preload hint
  • Cache-Control: public, max-age=31536000, immutable on font responses
  • Access-Control-Allow-Origin header on font server (especially if using a CDN subdomain)
  • Filename includes content hash or version so cache busting is automatic
  • Verified in Lighthouse: no "font is render-blocking", CLS score acceptable

Fallback Font Stack: The Underrated Part

The gap between your web font loading and the fallback renders matters. If Inter loads and your fallback is Arial, the metrics are close enough that layout shift is minimal. If your fallback is Georgia (serif vs. sans-serif), you get a jarring reflow.

Match your fallback as closely as possible. The size-adjust, ascent-override, descent-override, and line-gap-override descriptors in @font-face let you tune the fallback font’s metrics to match your web font:

/* Fallback that mimics Inter's metrics closely */
@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  ascent-override: 90%;
  descent-override: 22.43%;
  line-gap-override: 0%;
  size-adjust: 107.64%;
}

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

These values aren’t magic numbers — tools like fontpie calculate them from your actual font file. Run it once per font and commit the values.


The Minimal Setup That Covers 90% of Sites

If you want the practical summary without wading through every tradeoff: download your font as TTF from Google Fonts or the author’s GitHub, run pyftsubset on it, serve the WOFF2 from your domain with immutable caching headers, write a clean @font-face block with font-display: swap (unless you’re obsessing over CLS, in which case optional + preload), and add a single <link rel="preload"> for your primary body font above the stylesheet link.

That’s it. No Google tracking, no external DNS lookups, no GDPR exposure, and your fonts are probably loading faster than they were before. The whole pipeline takes about 20 minutes to set up properly and you never touch it again until you change fonts.

👁 Views: 202,921 · Unique visitors: 57,427