How to compress fonts to WOFF2 for the web

Most fonts on the web are still served as TTF or OTF. That's a 200-400KB hit per weight on every page load, blocking text render until the file arrives. WOFF2 fixes this. A typical TTF compressed to WOFF2 drops to 15-50KB without losing a single glyph or breaking variable-axis controls.
This guide walks through the actual workflow: what to subset, what to drop, how to verify the result.
Why WOFF2 wins over TTF and WOFF
WOFF2 is just a compressed font wrapper. The font tables inside are identical to a TTF. The wrapper uses brotli compression at quality level 11, which beats the gzip-based WOFF1 by another 30% on average. Browsers since 2020 (Chrome, Safari, Firefox, Edge) all support it natively.
No browser today needs anything other than WOFF2 for screen text. EOT and TTF fallbacks were a 2014-era concern. You can ship one format and call it done.
Step 1: subset to the glyphs you actually need
A full Latin font ships with 700+ glyphs covering accented Polish, Vietnamese, Turkish characters most English-language sites never render. Cutting to Basic Latin (U+0020 to U+007E, the 95 ASCII printable characters) drops a font from 200KB to maybe 20KB before WOFF2 even kicks in.
With fontTools (Python):
from fontTools.ttLib import TTFont
from fontTools.subset import Subsetter, Options
font = TTFont('Inter.ttf')
options = Options()
options.layout_features = ['*']
options.hinting = False
subsetter = Subsetter(options=options)
subsetter.populate(unicodes=set(range(0x0020, 0x007F)))
subsetter.subset(font)
font.flavor = 'woff2'
font.save('Inter.woff2')
That's it. The result is a fully working WOFF2 with just the characters you specified.
If your site uses smart quotes, em-dashes, or accented European characters, widen the range to include Latin Extended-A (U+0100 to U+017F) and General Punctuation (U+2010 to U+205F). Adds about 2KB.
Step 2: drop unused tables
Fonts ship with tables that mean nothing on the web:
- DSIG: digital signature, not validated by any browser
- VORG: vertical writing origin (Japanese vertical text)
- JSTF: justification feature data
- EBSC, EBLC, EBDT, CBDT, CBLC, sbix: embedded bitmaps and color glyphs (you don't need these for plain UI text)
- fpgm, prep, cvt: TrueType hinting bytecode (browsers use auto-hinting; manual hints are mostly ignored on modern displays)
Dropping them shaves another 5-10KB on most fonts. fontTools handles this with options.drop_tables.
Step 3: instance variable fonts to the axes you need
This one is the biggest win for variable fonts. A modern variable font like Roboto Flex ships with 13 axes including grade, optical size, slant, width, plus weight. Most projects only use weight and italic. Pinning the other 11 axes to their default values ("instancing") drops Roboto Flex from 200KB to about 20KB.
from fontTools.varLib.instancer import instantiateVariableFont
font = TTFont('RobotoFlex.ttf')
to_pin = {a.axisTag: a.defaultValue for a in font['fvar'].axes
if a.axisTag not in {'wght', 'ital'}}
font = instantiateVariableFont(font, to_pin)
The font is still variable on weight and italic, so your CSS font-weight: 100 900 and font-style: italic still work. You just lost the axes you weren't using.
Step 4: serve it with the right CSS
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
font-display: swap is the important bit. Without it, the browser blocks text rendering for up to 3 seconds while the font downloads. With swap, the user sees fallback text immediately and the real font swaps in once it lands.
For variable fonts, the font-weight: 100 900 declaration tells the browser this single file covers every weight. CSS font-weight: 600 then renders by interpolating along the axis at runtime.
Step 5: verify the result
A WOFF2 file should be smaller than the source TTF and render identically. Three quick checks:
1. Open the WOFF2 in any browser (Chrome's DevTools Network tab will show its size) 2. Render text at varying weights to confirm variable axes still work 3. Run it through a font validator to confirm tables aren't corrupted
If your output is suspiciously large (over 100KB for a single weight), something is wrong: probably the subsetter fell back to keeping all glyphs because of an unusual table layout in the source. Try dropping more layout features or re-fetching the original from a clean source.
Doing this without writing code
Drop your TTF or OTF onto Font Compressor and you get a properly subsetted WOFF2 in a few seconds. Same pipeline as above. You can also browse the pre-compressed library for popular fonts like Inter, Playfair Display, JetBrains Mono, and 250 others.
Quick recap
1. Subset to the unicode range your site actually uses 2. Drop unused tables (DSIG, VORG, JSTF, embedded bitmaps, hinting) 3. Instance variable fonts to the axes you need 4. Save as WOFF2 with font-display: swap 5. Verify the result is smaller than the source and renders correctly
Done right, this turns a 350KB font load into a 20KB one. Across a typical 3-weight site, that's a 1MB-per-page-load saving.