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.
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:
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
pip install fonttools brotli # brotli is required for WOFF2 outputStep 2: inspect the metrics first (we need them later)
fonttools ttx -o - -t head -t hhea Inter-Variable.ttf | grep -E "unitsPerEm|ascent|descent|lineGap"Step 3: subset to what you use
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.woff2What 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:
# 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.woff2If you only use, say, weights 400 to 700, limit the variable axis first and subset the result:
fonttools varLib.instancer Inter-Variable.ttf wght=400:700 -o Inter-400-700.ttfFinally, 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
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:
size-adjust = webFontAvgWidth / fallbackAvgWidth
ascent-override = (webAscent / unitsPerEm) / size-adjust
descent-override = (|webDescent| / unitsPerEm) / size-adjust
line-gap-override = (webLineGap / unitsPerEm) / size-adjustFor Inter against Arial: with unitsPerEm = 2048, ascent 1984 and descent −494, and a size adjustment of about 1.074:
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:
/* 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
app/
├── fonts/
│ ├── inter-latin-var.woff2
│ └── inter-latin-ext-var.woff2
├── styles/
│ └── fonts.css
└── root.tsxPreloading 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.
// 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:
// 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.
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 downloadThe 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:
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.

Written by Nazmul Hawlader
Top RatedSenior 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.