How to Optimize Web Fonts with Variable Font Subsetting

By subsetting a variable font with pyftsubset to include only the Unicode ranges and OpenType features your site actually needs, and serving it as a WOFF2 file with the CSS unicode-range descriptor, you can reduce web font payload by 70-85%. A typical setup drops a 300 KB variable font to under 40 KB while keeping full weight and italic axis support for every glyph you actually use. This post walks through the entire process from font selection to CI integration.
Variable Fonts in 2026 - What Has Changed
Variable fonts have gone from experimental to the default choice for most web projects. All major browsers - Chrome 124+, Firefox 130+, Safari 18+, Edge 124+ - fully support variable fonts including font-variation-settings, font-weight ranges, font-stretch, and font-style: oblique ranges. There are no meaningful holdouts left in the browser landscape.
Google Fonts
now serves variable fonts by default for families that support them. When you request Inter, you get Inter-VariableFont_opsz,wght.woff2 covering weight 100-900 and optical size 14-32 in a single file, rather than the old approach of downloading four or five separate static font files.
A single Inter variable font file (wght + opsz axes) weighs about 310 KB as WOFF2. Compare that to loading four static weights (Regular, Medium, SemiBold, Bold) which totals around 340 KB across four separate files. The variable font is already slightly smaller even before subsetting, and it gives you access to every weight in between rather than just four discrete options.
A few newer CSS properties work well alongside variable fonts. The font-palette and font-variant-emoji properties (stabilized across browsers in 2025-2026) allow color font customization without adding payload. The size-adjust descriptor in @font-face (supported since 2024) works alongside variable fonts to precisely match fallback font metrics, which eliminates Cumulative Layout Shift during font loading without resorting to FOIT or FOUT hacks.
The CSS font-synthesis-weight property, which gained wide support in 2026, lets browsers synthesize missing weights within a variable font’s axis range. This is useful when a font only covers 300-700 but your design calls for weight 200 - the browser can extrapolate rather than falling back to a system font.
Variable fonts are now the performance-optimal choice even before any optimization work. The bigger wins, though, come from subsetting.
Subsetting with pyftsubset - Step by Step
pyftsubset
from the fonttools Python library is the standard tool for stripping unused glyphs, features, and axes from font files. It is well-maintained, widely used in production, and handles variable fonts correctly.
Install it with pip:
pip install fonttools[woff]The [woff] extra pulls in Brotli support needed for WOFF2 output. You need Python 3.10 or newer.
Here is a basic Latin subset command for Inter:
pyftsubset Inter-VariableFont_opsz,wght.woff2 \
--output-file=inter-latin.woff2 \
--flavor=woff2 \
--unicodes="U+0020-007F,U+00A0-00FF,U+2000-206F" \
--layout-features="kern,liga,calt,ss01"This takes Inter from 310 KB down to roughly 35 KB. That is an 89% reduction from a single command.
The --unicodes flag accepts Unicode ranges. Here is what each range covers:
| Range | Name | What It Includes |
|---|---|---|
| U+0020-007F | Basic Latin | ASCII printable characters, letters, digits, punctuation |
| U+00A0-00FF | Latin-1 Supplement | Accented characters for Western European languages |
| U+2000-206F | General Punctuation | Em dashes, en dashes, smart quotes, ellipsis |
For most English-language sites, these three ranges cover everything you need. If your content includes occasional French, German, Spanish, or other Western European text, the Latin-1 Supplement range already has you covered.
You can also constrain the variable axes themselves. The --variations flag limits the weight axis to only the range your design actually uses:
pyftsubset Inter-VariableFont_opsz,wght.woff2 \
--output-file=inter-latin.woff2 \
--flavor=woff2 \
--unicodes="U+0020-007F,U+00A0-00FF,U+2000-206F" \
--layout-features="kern,liga,calt" \
--variations="wght:300:700"This strips data for weights 100-299 and 701-900, saving another 15-20% on file size. If your site only uses Regular (400) through Bold (700), there is no reason to ship the thin and black weight data.
The --layout-features flag controls which OpenType features are preserved. kern is kerning (letter spacing adjustments), liga is standard ligatures (like fi and fl), and calt is contextual alternates. Dropping small caps (smcp), stylistic sets (ss01 through ss20), and other features you do not use can save several kilobytes.
For multi-language sites, the best approach is to create separate subset files per script and let CSS handle which one to load:
inter-latin.woff2- roughly 35 KBinter-cyrillic.woff2- roughly 12 KBinter-greek.woff2- roughly 8 KB
Each file contains only the glyphs for its script, and the browser downloads only what the page actually needs.
CSS unicode-range and @font-face Strategy
The CSS unicode-range descriptor tells the browser to download a font file only if the page contains characters within that range. Combined with subsetting, this means visitors to your English-only pages never download Cyrillic or Greek font data.
Define multiple @font-face blocks for the same font-family, each pointing to a different subset file:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0020-00FF, U+2000-206F;
font-weight: 300 700;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-cyrillic.woff2') format('woff2');
unicode-range: U+0400-04FF;
font-weight: 300 700;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-greek.woff2') format('woff2');
unicode-range: U+0370-03FF;
font-weight: 300 700;
font-display: swap;
}The font-weight: 300 700 range declaration tells the browser that this single file handles all weights from 300 to 700. Without the range syntax, the browser might try to synthesize faux bold, which looks noticeably worse than the actual bold from the variable font.
For font-display, use swap for body text. This shows the fallback font immediately and swaps in the web font once it loads. For decorative or non-essential fonts, optional prevents any layout shift at the cost of sometimes not showing the web font at all on slow connections.
To eliminate layout shift, the key step is matching your fallback font metrics to the web font. Use size-adjust and the override descriptors in a separate @font-face rule for the fallback:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial'), local('Helvetica');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
}
body {
font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
}When the browser shows Arial as a fallback while Inter loads, the size-adjust and override properties scale Arial to match Inter’s metrics almost exactly. The swap becomes visually imperceptible.
Finally, preload your primary subset file in the HTML <head>:
<link rel="preload" href="/fonts/inter-latin.woff2"
as="font" type="font/woff2" crossorigin>This starts the font download before the browser even parses your CSS, shaving 100-200ms off the font loading time on typical connections. Only preload the Latin subset (or whichever script is primary for your audience) since preloading everything defeats the purpose of unicode-range splitting.
Measuring the Performance Impact
Numbers matter more than claims here, so here is what a before/after comparison looks like in practice.
Before optimization: A typical site loading Inter in four weights (Regular, Medium, SemiBold, Bold) as separate static WOFF2 files totals about 340 KB across four HTTP requests. Font-related CLS typically measures 0.08-0.15 from the flash of unstyled text during font swap.
After optimization: A single subsetted variable Inter file at 35 KB in one HTTP request, with CLS of 0.00-0.02 using size-adjust and font-display: swap. That is a 90% reduction in font payload and near-zero layout shift.
| Metric | Before | After | Improvement |
|---|---|---|---|
| Total font payload | 340 KB | 35 KB | -90% |
| HTTP requests (fonts) | 4 | 1 | -75% |
| CLS from font loading | 0.08-0.15 | 0.00-0.02 | Near zero |
| Font loading time (3G) | 2.1s | 0.4s | -81% |
To verify these numbers on your own site, run Lighthouse 12.x:
lighthouse https://yoursite.com --only-categories=performance --output=jsonCheck the “Ensure text remains visible during webfont load” audit. With font-display: swap configured, this should pass. Also look at the CLS breakdown to confirm font loading is no longer a contributor.
Chrome DevTools Network tab with “Slow 3G” throttling is another good way to visualize this. Look for the inter-latin.woff2 request in the waterfall. With preloading, it should start near the top of the waterfall rather than waiting for the CSS to be parsed and evaluated. The difference in start time is typically 150-300ms on throttled connections.
WebPageTest
filmstrip view shows the exact frame where the font swap occurs. With size-adjust tuned correctly, the swap should be invisible in the filmstrip - you should not be able to identify which frame is showing the fallback and which is showing the web font.
After deploying font optimizations to production, monitor Core Web Vitals field data via the Chrome UX Report (CrUX). Expect LCP improvement of 100-300ms on mobile (since font loading often blocks or delays largest contentful paint) and CLS improvement from 0.10+ to under 0.05.
Automation and Build Pipeline Integration
Font subsetting works best when it runs automatically as part of your build, not as something someone has to remember to do manually.
Here is a subset-fonts.sh script that handles the common case:
#!/bin/bash
set -euo pipefail
FONT_DIR="./fonts/source"
OUTPUT_DIR="./static/fonts"
UNICODE_LATIN="U+0020-007F,U+00A0-00FF,U+2000-206F"
FEATURES="kern,liga,calt"
WEIGHT_RANGE="wght:300:700"
mkdir -p "$OUTPUT_DIR"
for font in "$FONT_DIR"/*-VariableFont_*.woff2; do
basename=$(basename "$font" .woff2)
slug=$(echo "$basename" | tr '[:upper:]' '[:lower:]' | sed 's/_/-/g')
pyftsubset "$font" \
--output-file="$OUTPUT_DIR/${slug}-latin.woff2" \
--flavor=woff2 \
--unicodes="$UNICODE_LATIN" \
--layout-features="$FEATURES" \
--variations="$WEIGHT_RANGE"
echo "Subsetted: $font -> $OUTPUT_DIR/${slug}-latin.woff2"
doneIn a Hugo site, wire it into a Makefile:
.PHONY: fonts build
fonts:
bash subset-fonts.sh
build: fonts
hugo --minify
serve: fonts
hugo server -D
This ensures fonts are subsetted before every build. Hugo’s asset pipeline then picks up the files from static/fonts/ and serves them directly.
For CI/CD with GitHub Actions , add a step before the build:
- name: Subset fonts
run: |
pip install fonttools[woff]
bash subset-fonts.shFor a more aggressive optimization, use glyphhanger
from Filament Group. This tool scans your built HTML files and determines exactly which Unicode characters appear on each page. You can then feed that character list to pyftsubset for a perfectly tailored subset:
glyphhanger ./public --formats=woff2 --subset=fonts/source/Inter.woff2This approach can produce even smaller files than range-based subsetting because it only includes the specific characters your content uses rather than entire Unicode blocks.
Finally, add a size budget check to catch regressions. A simple check in your CI pipeline:
MAX_SIZE=40960 # 40 KB in bytes
ACTUAL_SIZE=$(stat -c%s static/fonts/inter-latin.woff2)
if [ "$ACTUAL_SIZE" -gt "$MAX_SIZE" ]; then
echo "Font file exceeds size budget: ${ACTUAL_SIZE} bytes > ${MAX_SIZE} bytes"
exit 1
fiIf someone adds new OpenType features, extends the weight axis, or includes additional Unicode ranges, the build fails and forces a review of whether the addition is justified.
File Size Comparison Across Popular Variable Fonts
To give a sense of what subsetting achieves across different typefaces, here are before and after numbers for some widely used variable fonts, all subsetted to Basic Latin + Latin-1 Supplement with kerning and ligatures preserved:
| Font | Full Variable WOFF2 | Subsetted Latin WOFF2 | Reduction |
|---|---|---|---|
| Inter (wght + opsz) | 310 KB | 35 KB | 89% |
| Roboto Flex (14 axes) | 1.2 MB | 48 KB | 96% |
| Source Sans 3 (wght) | 190 KB | 28 KB | 85% |
| Noto Sans (wght) | 560 KB | 42 KB | 92% |
Roboto Flex is an extreme example because its 14 variable axes include width, optical size, grade, and several parametric axes. Most sites only need weight and maybe optical size, so stripping the unused axes produces dramatic savings.
Cache Busting and Long-Term Caching
Since subsetted font files change rarely, they are excellent candidates for aggressive caching. Set Cache-Control: public, max-age=31536000, immutable for font files and use content hashes in filenames to handle cache busting.
Hugo handles this automatically through its asset fingerprinting:
{{ $font := resources.Get "fonts/inter-latin.woff2" | fingerprint }}
<link rel="preload" href="{{ $font.RelPermalink }}"
as="font" type="font/woff2" crossorigin>This produces a URL like /fonts/inter-latin.a3f2c1b8.woff2 that changes only when the file content changes. CDNs can cache it indefinitely, and browsers will fetch the new version automatically when you update the font.
For non-Hugo sites, most build tools (Webpack, Vite, Parcel) have equivalent hashing plugins. Font files should never be served with short cache times or no-cache headers - they change so rarely that a year-long cache is appropriate.
Putting It All Together
A subsetted variable font at 35 KB replaces 340 KB of static font files and gives you continuous weight access instead of four discrete options. Pair that with unicode-range splitting, preloading, and size-adjust fallback matching, and font loading stops being a source of layout shift or slow page loads.
pyftsubset handles variable fonts correctly, browser support is complete, and the whole subsetting process fits into a build script that finishes in seconds. If your site still loads multiple static font weights or ships a full unsubsetted variable font, this is worth the hour or two it takes to set up.