WOFF2, TTF, OTF — which file goes where
Four extensions, two real formats, and one decision that matters more than all of them. What to serve on the web, what to install, and why the file you ship should not be the file you sell.
A font arrives as .ttf, .otf, .woff, .woff2, and every guide tells you to use WOFF2 without saying what the others are or why they exist. Here is the short, accurate version, followed by the part that actually costs money if you get it wrong.
There are two formats and two wrappers
TrueType and PostScript/CFF are the two ways of describing an outline. TrueType uses quadratic curves; CFF uses cubics. .ttf usually means the first, .otf usually means the second, and both are OpenType — the container has been the same since 1996. Which one you have affects file size slightly and rendering hardly at all on any screen made this decade.
WOFF and WOFF2 are not formats. They are compression wrappers around one of the above. WOFF2 uses Brotli and is roughly 30% smaller than WOFF, which used zlib.
So the answer to "which one" is boring:
- Serving to browsers: WOFF2. Nothing else. Support has been universal since 2018, and the fallback chain people still write for IE11 is now several kilobytes of CSS defending against a browser nobody has.
- Installing on a machine: TTF or OTF. Both install on macOS, Windows and Linux. WOFF2 does not install anywhere — it is a web delivery format and the OS font manager will refuse it.
That is the whole of the format question. Now the part that matters.
The file you serve should not be the file you sell
Here is a real number from this site. Our largest face covers 12,201 characters — Latin, Greek, Cyrillic, Korean syllables, Japanese kana, Thai.
A page that sets an English headline has no use for eleven thousand Korean syllables, but the browser downloads the file before it can know that. Subsetting is not an optimisation here; it is the difference between a font a page can use and one it cannot.
Subsetting with pyftsubset:
pyftsubset font.ttf \
--unicodes="U+0000-00FF,U+2000-206F,U+2190-21FF" \
--layout-features='*' \
--flavor=woff2 \
--output-file=font.subset.woff2
Keep --layout-features='*' unless you know which features you are dropping. Subsetting the feature tables is where kerning quietly disappears.
Loading it without the flash
@font-face {
font-family: 'Typefound Quill';
src: url('/fonts/quill.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
font-display is the only property in that block that people get wrong, and it has five values that are really three decisions:
| Value | Block | Swap | In practice |
|---|---|---|---|
auto |
~3s | infinite | Whatever the browser feels like |
block |
3s | infinite | Invisible text, then your font |
swap |
0ms | infinite | Fallback immediately, then your font |
fallback |
100ms | 3s | Brief flash, then gives up |
optional |
100ms | 0ms | May never use your font at all |
For a display face on a headline, swap is right: the text is readable instantly and the headline redraws. For body text, swap is the one that produces the jarring reflow everybody hates — optional is worth considering, because body text that renders in the fallback is a page that still works.
The reflow is fixable
The jump when the font swaps is not caused by the swap. It is caused by the fallback having different metrics. You can match them:
@font-face {
font-family: 'Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
Set those so the fallback occupies the same vertical space as the real font and the swap stops moving anything. It is fiddly to derive by hand; the Fallback Font Generator and fontaine both compute the numbers for you.
Preload the one that matters
<link rel="preload" href="/fonts/quill.woff2" as="font" type="font/woff2" crossorigin>
crossorigin is required even for same-origin fonts — fonts are fetched in CORS mode, and without it the browser fetches the file twice. Preload exactly one face, the one in the first screenful. Preloading five fonts is preloading nothing.
The cache trap nobody warns you about
/fonts/quill.woff2 is the same URL whatever is inside it. Change a glyph, redeploy, hard reload — and the old outlines come back, because a hard reload refetches the page, and the font was requested by CSS the page had already parsed. It comes from the disk cache, or from the CDN edge, and you will spend an afternoon convinced your build is broken.
Put a hash of the bytes in the URL:
src: url('/fonts/quill.woff2?v=3f9a21c4') format('woff2');
Same reason bundlers hash their output. Fonts are the one asset most projects forget to do it for.
A short checklist
- Serve WOFF2 only. Delete the WOFF and EOT fallbacks.
- Subset to the characters your pages actually set — but keep
--layout-features='*'. font-display: swapfor display type, and consideroptionalfor body.- Override the fallback's metrics so the swap does not move the page.
- Preload one file, with
crossorigin. - Hash the URL, or you will be debugging a cache.
- Keep the licensed original somewhere your web build cannot accidentally serve.
That last one is not a performance point. It is the one that has your customers in it.