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.

Browser decision walk through the src fallback list An evergreen browser stops at WOFF2; a pre-WOFF2 browser skips to WOFF; neither downloads more than one file. Parse src list top entry first woff2 supported? download woff2, stop else next entry download woff, stop Exactly one file is downloaded per face, regardless of browser age. No TTF entry: raw TTF is larger and only old, extinct browsers would use it.
The browser short-circuits at the first format it supports, so WOFF2-first costs old browsers nothing.

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:

  1. 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.
  2. 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.
  3. 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

  1. Open Chrome DevTools, select the Network panel, filter by Font, and reload with the cache disabled.
  2. Confirm WOFF2 wins. On an evergreen browser, exactly the .woff2 file should appear — the .woff must not be requested. If you see the .woff fetched, your src order is wrong.
  3. 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.
  4. Confirm runtime registration. In the Console, run document.fonts.check('400 1rem Inter') — it returns true once the face is loaded.
  5. Confirm no double-fetch. Check the Console for a "preloaded but not used" warning, which signals a crossorigin or 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 to auto (treated like block), giving up to 3s of invisible text. Always set swap, fallback, or optional.
  • Mismatched font-weight/font-style across 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.

Related