Choosing WOFF2 vs WOFF vs TTF in @font-face
This page sits under the Font Format Strategy guide and the broader Font Loading & Delivery Strategies blueprint. It solves one narrow but high-leverage detail: writing the @font-face { src: ... } declaration so that every browser downloads the smallest file it can decode, and old browsers degrade without breaking.
Problem Statement
Many @font-face blocks list formats in the wrong order, omit the format() hint, or still include TTF as a web-served source. The browser walks the src list top to bottom and stops at the first format it supports, so a single misordered line can make a capable browser download a 30–50% larger file than necessary, or skip a file it could have used. You want one declaration that reliably serves WOFF2 to the 98%+ of traffic that supports it and falls back cleanly for the rest.
Prerequisites
- WOFF2 assets already generated (subset and converted from your TTF/OTF source with
pyftsubset --flavor=woff2). - An optional WOFF copy only if you must support pre-2016 browsers.
- Fonts served same-origin (or with correct CORS headers) and
Cache-Control: public, max-age=31536000, immutable. - A chosen font-display value so text never blocks while the font loads.
Implementation
The primary, correct declaration lists WOFF2 first, then WOFF, each with an explicit format() hint.
Correct @font-face: WOFF2 first, graceful degradation to WOFF
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-400.woff2') format('woff2'),
url('/fonts/inter-latin-400.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
Annotating the critical lines: the order of the src list is the whole game. The browser reads it top to bottom and downloads the first entry whose format() it understands, ignoring the rest. WOFF2 sits first, so any evergreen browser grabs it and never fetches the WOFF — there is zero penalty for keeping the fallback. The format('woff2') and format('woff') hints let the browser decide without downloading the file first; omit them and some engines must speculatively fetch to sniff the type. There is deliberately no TTF entry: a raw TTF is uncompressed and 30–50% larger, and shipping it would only help browsers that have been extinct for years. font-weight, font-style, and font-display are explicit so the face maps to exactly one weight and never blocks text rendering.
Defensive Variant: Feature/Format Detection
When you need to confirm support before committing — for example to conditionally preload, or to choose an asset in JS — use the tech()/format() feature test via CSS.supports and the FontFace constructor, which throws on unsupported sources.
Defensive: detect WOFF2 support and construct the face programmatically
// 1. Quick capability check for variable + woff2 support.
const supportsWoff2 = CSS.supports('font-tech', 'color-COLRv1')
|| 'FontFace' in window; // FontFace API implies modern engine + woff2
async function loadInter() {
try {
// 2. Try the smallest format first. FontFace.load() rejects if the
// browser cannot decode the source, so we can fall back in catch.
const face = new FontFace(
'Inter',
"url('/fonts/inter-latin-400.woff2') format('woff2')",
{ weight: '400', display: 'swap' }
);
await face.load(); // throws on decode/network failure
document.fonts.add(face); // register only after a successful load
} catch (err) {
// 3. Graceful degradation: fall back to the WOFF face declared in CSS,
// or simply let the system fallback stack render. Never leave text invisible.
console.warn('WOFF2 load failed, using CSS fallback face', err);
}
}
if (supportsWoff2) loadInter();
The defensive pattern matters when font loading is on the critical path: FontFace.load() returns a promise that rejects on a decode or network failure, so a try/catch lets you fall back to the CSS-declared WOFF face or the system stack instead of leaving the user with invisible text. Pair this with the CSS Font Loading API to coordinate when text becomes visible.
Why Order Beats Everything Else Here
It is tempting to think the format() hint is what selects the file, but the hint only describes each entry — the position in the list is what decides the outcome. The browser evaluates entries top to bottom and commits to the first one whose format it can decode, without looking further. So three rules follow directly:
- Smallest-supported-format first. WOFF2 is smallest and the most widely supported modern format, so it always goes first. There is no scenario where putting WOFF or TTF above it helps.
- Fallbacks below, free of charge. Anything below the first supported entry is never fetched by a browser that supported an earlier entry, so a WOFF (or even TTF) fallback adds zero cost for modern browsers — it is pure insurance for old ones.
- No TTF in production at all. Even at the bottom of the list, a web-served TTF only serves browsers that predate WOFF2 (pre-2016), an audience close to zero. The correct fallback, if you want one, is WOFF.
This is why the canonical declaration is two lines, WOFF2 then WOFF, and nothing else. If your analytics show a flat zero on pre-2016 browsers, collapse it to a single WOFF2 line.
How WOFF2 Beats WOFF on Bytes
The two formats wrap the same OpenType tables; the difference is the compressor. WOFF uses Zlib (DEFLATE); WOFF2 uses Brotli plus a font-specific table transform, yielding roughly 30% smaller files for the same glyphs. That gap is the entire reason WOFF2 leads the src list: you are handing capable browsers the smaller file and letting only genuinely old browsers pay the WOFF tax. Because WOFF2 is already compressed internally, do not double-compress it at the transport layer — serve it as Content-Type: font/woff2 with no extra Content-Encoding.
Verification
- Open Chrome DevTools, select the Network panel, filter by Font, and reload with the cache disabled.
- Confirm WOFF2 wins. On an evergreen browser, exactly the
.woff2file should appear — the.woffmust not be requested. If you see the.wofffetched, yoursrcorder is wrong. - Confirm size. The downloaded file's size should match your WOFF2 budget (under ~50KB for a Latin subset), not the larger WOFF or TTF size.
- Confirm runtime registration. In the Console, run
document.fonts.check('400 1rem Inter')— it returnstrueonce the face is loaded. - Confirm no double-fetch. Check the Console for a "preloaded but not used" warning, which signals a
crossoriginor family-name mismatch causing the font to download twice.
Generating the Files You List
The src declaration is only correct if the files behind it are built correctly. The canonical pipeline takes your TTF/OTF source, subsets it to the scripts you serve, and emits WOFF2 (and optionally WOFF):
Build the WOFF2 (and optional WOFF) from a TTF source
# Latin-only subset, compressed to WOFF2 — the file you list first.
pyftsubset Inter.ttf \
--output-file=inter-latin-400.woff2 \
--flavor=woff2 \
--unicodes="U+0000-00FF,U+2000-206F" \
--layout-features=kern,liga \
--no-hinting
# Optional WOFF fallback for pre-2016 browsers — list it second.
pyftsubset Inter.ttf \
--output-file=inter-latin-400.woff \
--flavor=woff \
--unicodes="U+0000-00FF,U+2000-206F" \
--layout-features=kern,liga \
--no-hinting
Run the same command per weight and style you reference. Confirm the WOFF output is roughly 30% larger than the WOFF2 — that size gap is the visible proof that the Brotli compression in WOFF2 is doing its job, and the reason WOFF2 leads the src list. For the full subsetting strategy, including splitting scripts across unicode-range declarations, see unicode-range subset loading.
Common Pitfalls
- WOFF listed before WOFF2. The browser stops at the first supported entry, so a WOFF-first order serves the heavier file to browsers that could have taken WOFF2.
- A TTF entry in production
src. It only helps extinct browsers and tempts a modern browser into the larger file if it sits above WOFF2. Remove it. - Missing
format()hints. Without them some engines speculatively download to sniff the type, wasting a request; a wrong hint can make a browser skip a file it supports. - No
font-display. A bare face defaults toauto(treated likeblock), giving up to 3s of invisible text. Always setswap,fallback, oroptional. - Mismatched
font-weight/font-styleacross faces. If the descriptors don't match how you use the family, the browser may synthesize bold/italic instead of using your file, distorting the glyphs.
FAQ
Does WOFF2-first hurt old browsers that don't support it?
No. The browser ignores entries whose format() it cannot decode and continues down the list, so a pre-WOFF2 browser simply skips the WOFF2 line and downloads the WOFF. WOFF2-first is strictly better: modern browsers stop early on the smaller file, old browsers fall through to the fallback.
Do I need a TTF entry for Safari?
No. Safari has supported WOFF2 since version 10 (and iOS Safari 10), released in 2016. A TTF fallback for Safari is unnecessary and only adds a larger file to your build.
Should I omit the WOFF fallback entirely?
For most sites, yes — WOFF2 covers over 98% of traffic, so a single WOFF2 source is simpler and smaller. Keep the WOFF fallback only if your analytics show a meaningful audience on browsers older than 2016.