Font Subsetting and Self-Hosting
Font Subsetting and Self-Hosting
The Symptom
The e-commerce platform loads two Google Fonts: a sans-serif for body text and a serif for editorial headings. The Google Fonts CSS file triggers requests to fonts.googleapis.com (for the CSS) and fonts.gstatic.com (for the font files). Each domain requires DNS resolution and a TLS handshake before the first byte of the font file begins downloading.
Timeline on a first visit, 4G connection:
- t=0ms: HTML received
- t=40ms: CSS parsed,
@importto Google Fonts discovered - t=190ms: DNS resolution for
fonts.googleapis.com(150ms) - t=350ms: TLS handshake (160ms)
- t=400ms: Google Fonts CSS received (50ms download)
- t=420ms: CSS parsed, font file URLs discovered
- t=570ms: DNS resolution for
fonts.gstatic.com(150ms) - t=730ms: TLS handshake (160ms)
- t=780ms: Font file download begins
- t=920ms: Font file received (sans-serif, 45KB, 140ms)
- t=920ms: Font swap triggers, text re-renders, CLS event
920ms from page load to font rendering. 460ms of that is DNS and TLS overhead for two third-party domains. The font file itself is only 140ms of download. The connection overhead costs 3.3x more than the actual font data.
The Cause
Google Fonts is a third-party CDN. Even though it is fast and globally distributed, every third-party origin adds connection overhead that is paid on every first visit. DNS lookup, TCP connection, TLS handshake. These steps run sequentially and each adds 50-200ms depending on the user’s network conditions.
Self-hosting the same fonts eliminates these connection costs entirely. The font files are served from the same origin as the HTML, CSS, and JavaScript. The browser already has a connection open. The font download starts immediately after the @font-face is parsed, with zero additional connection overhead.
The second issue is font file size. Google Fonts serves the full character set for each weight. For a Latin-alphabet e-commerce site, the font includes Cyrillic, Greek, Vietnamese, and extended Latin characters that are never displayed. These unused characters add 15-25KB per font file.
The Baseline
Google Fonts loading (two fonts, 4 weights total):
| Resource | Size | Connection Overhead |
|---|---|---|
| Google Fonts CSS | 1.2 kB | 310ms (DNS + TLS) |
| Sans-serif Regular | 45 kB | 310ms (DNS + TLS, new domain) |
| Sans-serif Bold | 46 kB | 0ms (reuses connection) |
| Serif Regular | 52 kB | 0ms (reuses connection) |
| Serif Bold | 53 kB | 0ms (reuses connection) |
| Total | 197 kB | 620ms |
Total time to font availability: 920ms (620ms connection overhead + 300ms download).
The Fix
Download the font files from Google Fonts, subset them, convert to WOFF2, and self-host:
# Download the original font files
# (Use google-webfonts-helper or download directly from Google Fonts)
# Subset to Latin characters only using pyftsubset
pip install fonttools brotli
pyftsubset CustomSans-Regular.ttf \
--output-file=custom-sans-regular-latin.woff2 \
--flavor=woff2 \
--layout-features='kern,liga,calt' \
--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+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD'
pyftsubset CustomSans-Bold.ttf \
--output-file=custom-sans-bold-latin.woff2 \
--flavor=woff2 \
--layout-features='kern,liga,calt' \
--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+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD'
The --unicodes range covers Basic Latin, Latin Extended-A essential characters, currency symbols (€), and common punctuation. For the e-commerce platform serving English and French content, this covers 100% of displayed characters.
Self-hosted CSS:
/* SLOW: Google Fonts import with third-party connection overhead */
@import url("https://fonts.googleapis.com/css2?family=Custom+Sans:wght@400;700&family=Custom+Serif:wght@400;700&display=swap");
/* FAST: Self-hosted, subsetted, with metric overrides */
@font-face {
font-family: "CustomSans";
src: url("/fonts/custom-sans-regular-latin.woff2") format("woff2");
font-weight: 400;
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+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "CustomSans";
src: url("/fonts/custom-sans-bold-latin.woff2") format("woff2");
font-weight: 700;
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+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "CustomSans-Fallback";
src: local("Arial");
ascent-override: 90.2%;
descent-override: 22.4%;
line-gap-override: 0%;
size-adjust: 105.3%;
}
body {
font-family: "CustomSans", "CustomSans-Fallback", system-ui, sans-serif;
}
WOFF2 is the only format needed. Every browser that supports @font-face also supports WOFF2. Including WOFF or TTF adds deployment weight for zero additional browser coverage.
Preload the critical font files:
<link
rel="preload"
href="/fonts/custom-sans-regular-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Only preload the regular weight. Bold text is typically not visible in the initial viewport and can load normally. Preloading all weights wastes bandwidth on resources that do not affect LCP or FCP.
The Proof
Self-hosted, subsetted font loading:
| Resource | Size | Connection Overhead |
|---|---|---|
| Sans-serif Regular (subsetted) | 18 kB | 0ms (same origin) |
| Sans-serif Bold (subsetted) | 19 kB | 0ms (same origin) |
| Serif Regular (subsetted) | 22 kB | 0ms (same origin) |
| Serif Bold (subsetted) | 23 kB | 0ms (same origin) |
| Total | 82 kB | 0ms |
| Metric | Google Fonts | Self-hosted | Delta |
|---|---|---|---|
| Font file total | 197 kB | 82 kB | -115 kB (-58%) |
| Time to font availability | 920ms | 260ms | -660ms |
| Connection overhead | 620ms | 0ms | -620ms |
| CLS from font swap | 0.08 | 0.00 | -0.08 |
The 660ms improvement in font availability comes from eliminating 620ms of connection overhead and 40ms of download time from smaller file sizes. CLS drops to zero because font-display: optional prevents the font swap layout shift entirely.
The CI Lighthouse gate detects the regression if someone accidentally reintroduces a Google Fonts import: the third-party domain request adds measurable latency that pushes LCP beyond the budget.
The Trade-off
Self-hosting means managing font updates manually. When the type foundry releases a new version with improved hinting or additional characters, you must download, subset, and redeploy. Google Fonts handles this automatically.
Self-hosting also means the font files are subject to your CDN caching strategy (Chapter 8). Google Fonts benefits from cross-site caching: a user who visited another site using the same font may have it cached. In practice, cross-site caching has been eliminated by browser cache partitioning (each origin gets its own cache partition), so this advantage no longer exists. Self-hosting is strictly better for performance in browsers that implement cache partitioning, which is all major browsers.
Subsetting removes characters. If the application later adds content in a language that uses characters outside the subset (Cyrillic, CJK), the font will not render those characters and the browser will fall back to the system font for those code points. The unicode-range descriptor limits which characters trigger the font download, but if the subset itself does not contain the character, it cannot be displayed in the web font. Monitor for tofu (□) characters in user-generated content as a signal that the subset needs expansion.