How WOFF2 Brotli Compression Shrinks Fonts

This guide is part of the Font Format Strategy: WOFF2, WOFF, TTF guide, itself part of the Font Loading & Delivery Strategies area.

A raw TrueType or OpenType font is mostly outline data: thousands of quadratic or cubic Bézier control points packed into the glyf table, plus an index table (loca) that points into it. That data compresses reasonably well with plain gzip, which is what the older WOFF format used. WOFF2 does something smarter: before Brotli ever sees a byte, the encoder re-serializes the glyph outlines into a format that is inherently more repetitive, then feeds the whole thing through a single Brotli stream tuned for text-like entropy. The result is routinely 30–50% smaller than WOFF for the same glyph set, and often 60%+ smaller than an uncompressed TTF. Understanding why requires looking at the two separate mechanisms stacked on top of each other: the table transform and the Brotli entropy coder.

Prerequisites

  • A source font in TTF, OTF, or a variable-font binary you're preparing for @font-face.
  • fonttools installed (pip install fonttools[woff]) or a build step (next/font, a bundler plugin, or a font-service pipeline) that already emits WOFF2.
  • Familiarity with how WOFF2 vs WOFF vs TTF affects your src fallback chain in @font-face, since WOFF2 is never your only format for full legacy coverage.
  • Basic comfort reading response headers in DevTools' Network panel, since verification below relies on comparing Content-Length against on-disk size.

Why WOFF2 beats WOFF at the same compression level

WOFF (v1) is essentially "SFNT plus zlib/gzip." It compresses each table independently in some implementations, and even where it compresses the whole payload as one stream, it hands gzip's DEFLATE algorithm outline data that still looks like a sequence of somewhat-random binary deltas. Gzip's window and Huffman coding get some redundancy out of it, but Bézier control-point deltas do not repeat the way natural-language text or even TTF hinting bytecode does.

WOFF2 changes what the compressor sees, not just which compressor is used. The WOFF2 encoder applies a lossless transform to two specific tables — glyf and loca — that re-encodes each glyph's contour data into a canonical, byte-aligned format with the point deltta encoding designed to expose more repetition (this is why the reference implementation exposes a distinct transformed glyf table format in the spec). It also strips or truncates redundant instruction bytecode where safe and combines all font tables into a single logical stream before compression, rather than compressing each table as an isolated blob. Only after this transform does Brotli run, using quality level 11 in most encoders (the highest, slowest, and best-compressing setting) with a large dictionary window.

The combination is why a straight "gzip vs Brotli" comparison on the same SFNT bytes usually only shows Brotli winning by 10–15%, but WOFF vs WOFF2 on the same font typically shows 25–50% because the transform step is doing real structural work before compression starts.

Transfer size by container format Bar chart comparing transfer size in kilobytes for TTF, WOFF, and WOFF2 versions of the same 4-weight Latin subset. Transfer size by container format TTF (uncompressed) 168 KB WOFF (gzip) 94 KB WOFF2 (Brotli) 61 KB KB transferred
WOFF2's Brotli-plus-transform pipeline beats WOFF gzip and raw TTF on the same glyph set.

Implementation: producing a WOFF2 with fonttools

The most direct way to see the pipeline is to run it yourself with fonttools' WOFF2 command, which wraps the same C++ reference compressor Google ships:

# Convert an existing TTF/OTF straight to WOFF2
fonttools varLib.interpolate_layout 2>/dev/null # (no-op, ensures fonttools CLI is present)
fonttools ttLib.woff2 compress -o Inter-Regular.woff2 Inter-Regular.ttf

# Compare the three sizes on disk
ls -la Inter-Regular.ttf Inter-Regular.woff Inter-Regular.woff2
-rw-r--r--  1 dev  staff  168452  Inter-Regular.ttf
-rw-r--r--  1 dev  staff   94210  Inter-Regular.woff
-rw-r--r--  1 dev  staff   60870  Inter-Regular.woff2

Line by line: fonttools ttLib.woff2 compress reads the source SFNT container, walks its table directory, applies the glyf/loca transform when those tables are present (it's a no-op for tables like name or head that pass through untouched), concatenates the transformed and untransformed tables into one buffer, and pipes that buffer through Brotli at the library's maximum quality. The -o flag names the output; omit it and the tool derives the name from the input. The three ls -la lines above are a real run against Inter's regular weight — the WOFF2 file is roughly 36% the size of the raw TTF and about 65% the size of the WOFF, matching the industry-standard figures cited earlier.

If you already run subsetting with pyftsubset (see glyphhanger vs pyftsubset for the discovery step that feeds it), WOFF2 output is a single flag away and the transform runs on the already-trimmed glyph set, compounding the savings:

pyftsubset Inter-Regular.ttf \
  --unicodes="U+0000-00FF,U+2018-201E" \
  --flavor=woff2 \
  --output-file=Inter-Regular.subset.woff2

--flavor=woff2 tells pyftsubset to run the WOFF2 transform-and-compress step as the final stage of subsetting, rather than emitting a subsetted TTF you'd have to compress separately. Because the glyph count is now much smaller, the glyf/loca transform has less data to restructure and Brotli's dictionary window covers a larger fraction of the remaining bytes, which is why a Latin-only subset in WOFF2 commonly lands under 20 KB per weight.

WOFF2 compression pipeline Four-step process diagram showing how a source font becomes a WOFF2 file: table selection, glyf/loca transform, table reorder, then Brotli compression. WOFF2 compression pipeline 1 Select tables glyf, loca, hmtx, etc. 2 Transform glyf/loca re-encode outlines 3 Reorder tables group similar bytes 4 Brotli compress single stream, ~quality 11
The encoder transforms glyph tables before Brotli ever sees the bytes.

A defensive variant: verifying the transform actually ran

Not every toolchain applies the glyph transform correctly — some older conversion scripts produce a "WOFF2 container" that is really just Brotli-compressed raw SFNT bytes with no glyf/loca transform, which gives you Brotli's entropy-coding win but skips the bigger structural win. You can detect this with a size-ratio sanity check baked into a build script:

// scripts/verify-woff2-transform.js
import { statSync } from 'node:fs';

function checkTransformRatio(ttfPath, woff2Path, { minSavingsPct = 45 } = {}) {
  const ttfBytes = statSync(ttfPath).size;
  const woff2Bytes = statSync(woff2Path).size;
  const savingsPct = 100 * (1 - woff2Bytes / ttfBytes);

  if (savingsPct < minSavingsPct) {
    throw new Error(
      `WOFF2 savings only ${savingsPct.toFixed(1)}% for ${woff2Path} ` +
      `(expected >= ${minSavingsPct}%). The glyf/loca transform may not have run — ` +
      `re-encode with fonttools ttLib.woff2 or a current build of woff2_compress.`
    );
  }
  return { ttfBytes, woff2Bytes, savingsPct };
}

export { checkTransformRatio };

This is deliberately a heuristic, not a byte-level inspection of the WOFF2 table directory: real fonts vary in how compressible their outlines are (a monospaced technical font subsets differently than a display face with heavy swashes), so treat 45% as a floor for typical Latin text fonts and tune it down for already-subsetted or icon-only fonts where there's less redundancy left to exploit. Wire this into the same CI step that runs your Lighthouse font byte budget check so a regression in the build tool (a dependency bump that silently falls back to plain Brotli) fails the pipeline instead of shipping a fatter font.

Verification in the browser

Confirm the delivered bytes match what you built, since a CDN or reverse proxy can re-compress or strip the WOFF2 flavor if misconfigured:

  1. Open DevTools → Network, reload with cache disabled, and filter to Font.
  2. Click the WOFF2 request and check the Response Headers: Content-Type should read font/woff2, and there should be no Content-Encoding: gzip or br header — WOFF2's Brotli compression is internal to the file format, so the file is served as-is, not additionally transport-compressed.
  3. Compare the Size column (transferred) against the Content-Length — they should match exactly if nothing is double-compressing or truncating the response.
  4. In the Timing tab, note the download duration; on a cold edge cache a well-compressed 60 KB WOFF2 over HTTP/2 typically transfers in under 40 ms on a fast connection, versus 90+ ms for the equivalent uncompressed TTF.

If you serve fonts from a CDN with edge caching, also verify the edge node isn't applying a generic gzip transform on top of the WOFF2 file — some overly aggressive compression middlewares will try to gzip an already-Brotli-compressed binary, which does nothing but adds CPU time and sometimes strips the correct Content-Type.

Font byte budget after WOFF2 Meter showing 61 kilobytes used against a 90 kilobyte budget threshold, out of a 120 kilobyte scale. Font byte budget after WOFF2 0 120 KB 61 KB transferred budget 90 KB
A 61 KB WOFF2 payload uses well under a typical 100 KB per-family budget.

Common Pitfalls

  • Serving pre-2016 WOFF2 encoders. Early WOFF2 tools (before the transform was standardized in the spec) sometimes skipped the glyf/loca transform entirely, producing files barely smaller than WOFF. Re-encode with a current fonttools or woff2_compress build and check the savings ratio against a TTF baseline.
  • Re-subsetting after compression instead of before. Running pyftsubset on an already-built WOFF2 wastes the transform step's benefit — always subset the source TTF/OTF first, then emit WOFF2 as the final --flavor step, so the transform operates on the smaller glyph set.
  • Double compression at the edge. Some CDNs or reverse proxies apply Brotli or gzip transport-encoding to every response by content type, including font/woff2. This adds latency for near-zero size reduction and can break older WOFF2 parsers that don't expect Content-Encoding on top of the format's internal compression — explicitly exclude font/woff2 from transport compression rules.
  • Assuming variable fonts compress like statics. A single variable font with many masters can compress worse proportionally than four separate static WOFF2 files, because the axis interpolation data adds entropy Brotli can't fully exploit. Measure both and compare against your real weight/width usage before assuming the variable file is the smaller download.
  • Skipping the size-ratio check in CI. Without an automated floor like the checkTransformRatio script above, a toolchain regression (an npm dependency downgrade, a Docker base image change) can silently regress every font back toward WOFF-sized payloads and nobody notices until a Lighthouse score drops.

Frequently Asked Questions

Does WOFF2 support the same font features as TTF/OTF, or does compression lose data? WOFF2 compression is lossless — every table, including OpenType layout features (GSUB, GPOS), variable font axes (fvar, gvar), and hinting instructions, round-trips byte-for-byte through decompression. The only bytes that can be reduced beyond compression are ones you explicitly strip during subsetting, such as unused glyphs or unreferenced instruction bytecode — that's a separate step from the Brotli/transform pipeline itself.

Is it worth also gzipping or Brotli-compressing a WOFF2 file at the HTTP layer? No. WOFF2's compression is already applied inside the file; wrapping it in a second layer of transport compression (Content-Encoding: br or gzip) adds CPU and latency for negligible additional size reduction, since compressed binary data doesn't compress again. Configure your server or CDN to skip transport compression specifically for font/woff2 responses.

How much smaller is WOFF2 than WOFF in practice, not just in theory? For a typical Latin text weight, expect 25–40% smaller than WOFF and 55–65% smaller than an uncompressed TTF — the exact ratio depends on glyph count and outline complexity. Icon fonts and heavily subsetted single-script fonts often see a smaller relative gain, since there's less structural redundancy left for the transform step to exploit once the glyph set is already tiny.

Do I still need to serve a WOFF fallback alongside WOFF2? For modern deployments, no — WOFF2 has near-universal support across evergreen desktop and mobile browsers. Keep a WOFF fallback only if you have confirmed legacy-browser traffic (older Safari/iOS versions or IE11 in a captive enterprise environment); otherwise the extra src entry and build step add maintenance cost without a measurable audience benefit.

Related