Performance & Web Vitals7 min readSeptember 3, 2026

Modern Web Font Optimization: WOFF2, Subsetting & Zero FOUT/FOIT

Stop downloading multi-megabyte Google Font files. Master WOFF2 compression, unicode-range glyph subsetting, and font preloading.

Nazmul Hawlader
Nazmul Hawlader
Senior Shopify & Full-Stack Engineer

The Hidden Cost of Third-Party CDNs

For years the default advice was "use Google Fonts, it's cached everywhere." In our production infrastructure we self-host every font, for reasons that are mostly about physics and partly about privacy.

The double cross-origin problem

A standard Google Fonts embed touches two origins:

  • fonts.googleapis.com serves the CSS.
  • fonts.gstatic.com serves the actual font files.

The browser can't discover the font file until it has downloaded and parsed the CSS, so it's a dependent chain. Each new origin costs a DNS lookup, a TCP handshake and a TLS negotiation, roughly three round trips before the first byte of a request goes out. At a 150 ms RTT, that's about 450 ms of pure connection setup per origin, before any data moves. preconnect hints move that setup earlier, but they don't remove it, and HTTP/2 connection coalescing can't share a connection across two different origins.

The "shared cache" benefit no longer exists

The old argument, that a visitor probably already has Inter cached from another site, died when browsers partitioned their HTTP caches by top-level site (Chrome, Firefox and Safari all do this now). Your copy of the font is cached for your site only, so the shared-cache benefit is gone while the extra connections remain.

Other costs

  • Volatile CSS caching. The Google Fonts CSS is served with a short cache lifetime (about a day at the time of writing) and varies by user agent, so it's revalidated far more often than a static asset you control.
  • Render-blocking. The stylesheet is render-blocking, so a slow response from a third party delays your first paint.
  • Privacy. Loading fonts from Google's servers sends visitors' IP addresses to a third party. European regulators and at least one court have treated this as a compliance issue. We aren't lawyers, so check with yours.
  • Availability and control. You can't set your own cache headers, preload reliably, or subset to your exact needs.

Self-hosting means fonts ride an already-open connection to your own origin, with your headers, your cache policy and no third-party dependency. That's why it wins, every time.

Font File Surgery: Modern WOFF2 Compression & Subsetting

Why WOFF2 dominates

WOFF2 wraps a font in Brotli compression and applies font-specific preprocessing (notably transforming the glyf and loca tables) before compressing. It's typically around 30% smaller than WOFF, and often over 50% smaller than the raw TTF or OTF. Browser support is universal in anything current, so we ship WOFF2 only and drop WOFF/TTF fallbacks. Every extra format in your src list is just noise.

Because WOFF2 is already Brotli-compressed, don't compress it again at the server or CDN. It gains nothing and burns CPU. Most compression layers skip font/woff2 by MIME type, but confirm yours does.

Here's the order of magnitude we see when going from a full family file to a shipped subset.

These are illustrative proportions, so measure your own files:

MARKDOWN SNIPPET
Illustrative sizes (relative bars, not measurements)

Full variable TTF          ████████████████████████████████████  ~100%
Full WOFF2 (all scripts)   ██████████████                        ~40%
WOFF2, Latin subset        ████                                  ~10%
WOFF2, Latin + trimmed     ███                                   ~7%
                                                                 (layout features + hinting removed)

Step 1: install the tooling

BASH SNIPPET
pip install fonttools brotli        # brotli is required for WOFF2 output

Step 2: inspect the metrics first (we need them later)

BASH SNIPPET
fonttools ttx -o - -t head -t hhea Inter-Variable.ttf | grep -E "unitsPerEm|ascent|descent|lineGap"

Step 3: subset to what you use

BASH SNIPPET
pyftsubset Inter-Variable.ttf \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" \
  --layout-features="kern,liga,calt,ccmp,locl,mark,mkmk" \
  --flavor=woff2 \
  --no-hinting \
  --output-file=inter-latin-var.woff2

What each choice does:

  • --unicodes is the range Google Fonts uses for its latin subset: Basic Latin, Latin-1 Supplement, punctuation, the euro sign and a few symbols. Verify it against your needs, since a store that sells in currencies beyond € needs those symbols too.
  • --layout-features keeps only the OpenType features we use: kerning, standard ligatures and contextual alternates, and the marks. It drops the stylistic sets, case-sensitive forms and other features we never enable. If your CSS uses font-feature-settings or font-variant-numeric such as tnum, add those features or they'll stop working.
  • --no-hinting strips TrueType hinting instructions, which modern rendering rarely needs.
  • --flavor=woff2 writes Brotli-compressed WOFF2 directly.

For a multi-language platform, produce one file per script with the standard ranges:

BASH SNIPPET
# Latin Extended (Polish, Czech, Turkish, Vietnamese fragments, ...)
pyftsubset Inter-Variable.ttf \
  --unicodes="U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF" \
  --layout-features="kern,liga,calt,ccmp,locl,mark,mkmk" \
  --flavor=woff2 --no-hinting \
  --output-file=inter-latin-ext-var.woff2

# Cyrillic
pyftsubset Inter-Variable.ttf \
  --unicodes="U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116" \
  --layout-features="kern,liga,calt,ccmp,locl,mark,mkmk" \
  --flavor=woff2 --no-hinting \
  --output-file=inter-cyrillic-var.woff2

If you only use, say, weights 400 to 700, limit the variable axis first and subset the result:

BASH SNIPPET
fonttools varLib.instancer Inter-Variable.ttf wght=400:700 -o Inter-400-700.ttf

Finally, check the font's license before subsetting or self-hosting. Open licenses like the SIL OFL generally allow it, but some commercial licenses don't.

Dynamic loading with unicode-range

Each @font-face rule with a unicode-range is treated as its own face, and the browser downloads a face only when the page renders characters in that range. An English page fetches the Latin file and never touches the Cyrillic one. That's how a multi-language platform ships one CSS file without paying for every script on every page.

Eradicating Layout Shifts: Zero FOUT & FOIT

What each flash actually costs

  • FOIT (Flash of Invisible Text): text is hidden while the font loads. It's expensive for LCP when the largest element is a heading or paragraph, because nothing paints until the font arrives or a timeout expires.
  • FOUT (Flash of Unstyled Text): text renders in a fallback font, then swaps. It's fast to first paint, but if the fallback and web font have different metrics, lines re-wrap and content moves, which is a direct hit on CLS.

font-display decoded

MARKDOWN SNIPPET
Block period → Swap period → Failure/fallback

auto      ~ same as block in most browsers
block     ├─ up to ~3s invisible ─┤ swap any time after  → FOIT risk
swap      ├─ ~0 ─┤ swap any time after                   → FOUT, layout-shift risk
fallback  ├─ ~100ms ─┤ swap only within ~3s              → compromise
optional  ├─ ~100ms ─┤ NO swap: stay on fallback          → no swap-induced shift
          (font still downloads and is cached for next navigation)

swap guarantees visible text, but it also guarantees a swap, and every swap is a potential layout shift. optional is the secret weapon for a zero-CLS budget: the browser gives the font a very short window (about 100 ms) to be ready, and if it isn't, the page stays on the fallback font for its whole lifetime. The font keeps downloading in the background and is cached, so subsequent pages and visits use it instantly.

The trade-off is honest and worth stating: on a slow first visit, some users never see your brand font on that page. We accept that because the second page view gets it, and because of the next technique, which makes the fallback nearly indistinguishable.

Metric overrides: pixel-matching the fallback

The shift happens because the fallback and web font differ in average glyph width, ascent, descent and line gap. CSS lets you correct the fallback to match the web font:

MARKDOWN SNIPPET
size-adjust        = webFontAvgWidth / fallbackAvgWidth
ascent-override    = (webAscent  / unitsPerEm) / size-adjust
descent-override   = (|webDescent| / unitsPerEm) / size-adjust
line-gap-override  = (webLineGap / unitsPerEm) / size-adjust

For Inter against Arial: with unitsPerEm = 2048, ascent 1984 and descent −494, and a size adjustment of about 1.074:

MARKDOWN SNIPPET
ascent  = (1984 / 2048) / 1.074 = 0.96875 / 1.074 ≈ 90.2%
descent = ( 494 / 2048) / 1.074 = 0.24121 / 1.074 ≈ 22.5%

We don't hand-derive size-adjust. It comes from average character widths, so we generate all four values with Capsize or Fontaine and use the math above to sanity-check them. Here's the complete stylesheet:

CSS SNIPPET
/* app/styles/fonts.css */
@font-face {
  font-family: "Inter";
  src: url("../fonts/inter-latin-var.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: optional;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
    U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Inter";
  src: url("../fonts/inter-latin-ext-var.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: optional;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* Metric-matched fallback: the "invisible" swap target */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107.4%;
  ascent-override: 90.2%;
  descent-override: 22.5%;
  line-gap-override: 0%;
}

:root {
  --font-sans: "Inter", "Inter Fallback", system-ui, -apple-system, "Segoe UI", sans-serif;
}
body { font-family: var(--font-sans); }

Two caveats we always verify: the fallback only works where local("Arial") actually exists (check your target platforms), and browser support for the override descriptors is uneven, since some browsers ignore them. Ignoring them degrades gracefully to the normal fallback.

Critical Font Preloading & the Asset Pipeline in Remix

The file layout

MARKDOWN SNIPPET
app/
├── fonts/
│   ├── inter-latin-var.woff2
│   └── inter-latin-ext-var.woff2
├── styles/
│   └── fonts.css
└── root.tsx

Preloading in root.tsx

With Remix on Vite, importing an asset with ?url returns its hashed production URL. The same file referenced from fonts.css resolves to the same URL, which matters because a preload URL that differs from the CSS URL causes a double download.

TYPESCRIPT SNIPPET
// app/root.tsx
import type { LinksFunction } from "@remix-run/node";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";

import interLatin from "~/fonts/inter-latin-var.woff2?url";
import fontsCss from "~/styles/fonts.css?url";

export const links: LinksFunction = () => [
  // Only the face used by above-the-fold text.
  // `crossOrigin` is required for font preloads, even for same-origin files.
  {
    rel: "preload",
    href: interLatin,
    as: "font",
    type: "font/woff2",
    crossOrigin: "anonymous",
  },
  { rel: "stylesheet", href: fontsCss },
];

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

The fonts load through plain <link> tags in server-rendered HTML, so there's no JavaScript involved and nothing to hydrate. That's what SSR-safe font delivery means: no FontFaceObserver or WebFont.load(), no client-side class toggling and no post-hydration flash.

The preloading trap

It's tempting to preload every weight and style. Don't. A preloaded font is fetched at high priority, competing with render-blocking CSS and the JavaScript that hydrates the page. On a constrained mobile connection, six preloads can delay scripts and push out LCP and interactivity. Our rules:

  • Preload one or two files, the faces used by above-the-fold text.
  • A variable font is one file for every weight, which is a strong reason to prefer it. Static families need one preload per weight you use.
  • Never preload script subsets or faces the page doesn't render, since the browser wastes bandwidth and warns about unused preloads.
  • optional effectively requires the preload. Without one, the font rarely arrives inside the 100 ms window.
  • Check for double downloads. In the Network panel, a font requested twice means the preload URL or crossorigin attribute doesn't match the CSS request.

Cache headers

Hashed asset filenames are safe to cache forever. With Remix's Express server:

TYPESCRIPT SNIPPET
// server.ts
import express from "express";

const app = express();

// Vite emits hashed files into /assets: cache for a year, immutable
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));

// Everything else in public/ is unhashed: keep the cache short
app.use(express.static("build/client", { maxAge: "1h" }));

This produces Cache-Control: public, max-age=31536000, immutable for fonts. Don't put fonts in public/ and give them the same header, because the filename doesn't change when the file does, so returning visitors could be stuck with stale fonts. On a CDN or static host, the equivalent is a _headers or platform rule matching /assets/*. Serve fonts from your own origin. A separate font domain reintroduces the connection cost and requires CORS headers.

Architectural Loading Breakdown

These timelines are illustrative, based on a 150 ms RTT (new origin = DNS + TCP + TLS = 3 round trips ≈ 450 ms). Each character is 50 ms.

MARKDOWN SNIPPET
A) UNOPTIMIZED: Google Fonts, no preconnect, font-display: swap

              0    500   1000  1500  2000 ms
              ┼─────┼─────┼─────┼─────┼
document      ████████████
googleapis                ░░░░░░░░░███           CSS (DNS+TCP+TLS, then request)
gstatic                               ░░░░░░░░░███▓▓   font (new origin again)
                                                     ▲
   text visible in fallback ────────────────┘        └─ swap: reflow → CLS
   (after CSS arrives)                                   (~1.9s)

B) SELF-HOSTED + SUBSET + PRELOADED WOFF2 (same origin, font-display: optional)

              0    500   1000  1500  2000 ms
              ┼─────┼─────┼─────┼─────┼
document      ████████████
font (preload)            ███▓▓                  reuses open connection
                               ▲
                               └─ first paint already uses final font (~0.85s),
                                  or stays on a metric-matched fallback: zero shift

Legend: ░ connection setup   █ request/response   ▓ file download

The difference comes from removing a dependency chain (CSS, then font, on new origins), shrinking the file so the download step nearly disappears, and starting the font request in parallel with the CSS through the preload.

Diagnostic checklist

Here's how we inspect font flashes in Chrome DevTools:

MARKDOWN SNIPPET
Network panel
├─ Throttle to "Slow 4G", tick "Disable cache"
├─ Filter by "Font"
├─ Right-click column headers → enable "Priority"
│   └─ preloaded fonts should be High; nothing should be requested twice
├─ Check the Initiator column: is the request coming from the preload or from CSS?
└─ Check response headers: immutable Cache-Control, no re-compression

Rendering panel (⋮ → More tools → Rendering)
├─ Enable "Layout Shift Regions" and reload
└─ Blue flashes on text blocks = font swap shifting layout

Elements panel
└─ Computed tab → "Rendered Fonts" shows which font actually painted a node
   (fallback vs. Inter, and how many glyphs)

Console
└─ [...document.fonts].map(f => [f.family, f.weight, f.status])
   shows which faces actually loaded, and which unicode-range faces never did

Lighthouse
└─ "Ensure text remains visible during webfont load" and "Preload key requests"

Test twice: once with a cold cache (first-visit behavior, where optional may show the fallback) and once warm (where the real font should appear immediately).

Engineering Summary

The production checklist we run on every project:

  • Self-host fonts on your own origin. No third-party font CDN, no extra connections.
  • WOFF2 only, with no re-compression at the server or CDN.
  • Subset by script (pyftsubset with real Unicode ranges), keep only the OpenType features you use, and strip hinting.
  • unicode-range faces so each page downloads only the scripts it renders.
  • font-display: optional for a zero-shift budget, paired with a metric-matched fallback (size-adjust, ascent-override, descent-override, line-gap-override).
  • Preload one or two files through Remix's links, using the ?url import so URLs match the CSS.
  • Hashed filenames with Cache-Control: public, max-age=31536000, immutable.
  • Verify with Network throttling, Layout Shift Regions and document.fonts on both cold and warm caches.

None of this is glamorous, but the result is visible: text appears immediately, nothing moves, and the type looks the way the designer intended. Fast, stable typography signals precision before a visitor reads a single word.

Nazmul Hawlader

Written by Nazmul Hawlader

Top Rated

Senior Full-Stack Engineer & Official Shopify App Store developer. Founder of Stockly and Kilo (kilo.nazmulcodes.org), specializing in high-performance Shopify apps, client-side media compression, and sub-second web performance.

Recommended Related Articles