Font Loading, Properly: The Platform-Agnostic part

Part 1 of 2. Part 2 covers how this lands in a SvelteKit project.
Web fonts are the single easiest way to make a fast site feel slow. Not because font files are large — a well-subsetted WOFF2 is often smaller than your logo — but because of where they sit in the loading sequence, and because the browser’s default behaviour when a font is late is to hide your text.
This half of the article is about the general problem. Every technique here works in plain HTML, in Rails, in Astro, in Next.js. Part 2 gets specific.
TL;DR
If you do nothing else:
- Self-host as WOFF2. The shared-cache argument stopped being true in 2020.
- Subset by script, and don’t touch
--layout-features. Automate it in your build. font-display: swap, plus metric-matched fallback overrides via Fontaine, plus an explicitline-heightso Safari — which ignores three of the four descriptors — still gets most of the benefit. This is the CLS fix.- Preload the one or two above-the-fold fonts — with
crossorigin, always. - Content-hash the filenames, cache
immutablefor a year.
1. Why fonts are on the critical path
The browser cannot request a font until it knows the font exists, and it doesn’t know that until it has parsed the CSS that declares it — which it can’t do until it has downloaded that CSS, which it can’t do until it has parsed enough HTML to find the stylesheet link.
HTML → CSS → @font-face → font file
Four sequential steps, minimum. Worse, browsers deliberately don’t download a declared font until they see an element that actually uses it. This is a sensible optimization — it avoids fetching the six weights you declared and used twice — but it pushes font discovery even later, into layout.
Everything that follows is one of two things: shortening that chain, or making the wait invisible. Keeping those two goals separate in your head is useful, because the techniques that shorten the chain (self-hosting, preloading) have essentially no downside, while the techniques that hide the wait (font-display, metric overrides) are all tradeoffs.
2. Self-host. The “shared cache” argument is dead.
The traditional defence of Google Fonts, Adobe Fonts, and friends went like this: sure, it’s a third-party origin, but the user has probably already downloaded Inter from fonts.gstatic.com on some other site, so it’s free.
That was true once. It hasn’t been true for years.
Browsers now partition the HTTP cache by the top-level site. Chrome shipped this in version 86 (October 2020), Firefox in 85 (January 2021), and Safari has partitioned in some form since 2013. A resource fetched on example.com is stored under a key that includes example.com, and a request for the identical URL on yoursite.com misses. Deliberately so — cross-site cache sharing was a fingerprinting and history-sniffing vector.
So what does using Google Fonts actually cost you today?
- DNS + TCP + TLS to
fonts.googleapis.com - Download the CSS
- Parse it, discover the font URL points at a different origin
- DNS + TCP + TLS to
fonts.gstatic.com - Download the font
Two extra connection setups on the critical path, in exchange for a cache benefit that no longer exists. Self-hosting collapses steps 1–4 into “it’s on a connection you already opened.”
The privacy angle
Because the font is fetched from Google’s servers, the user’s IP address, User-Agent, and Referer are transmitted to Google on every page load, with no consent step.
3. Ship WOFF2. Only WOFF2.
Every browser that matters has supported WOFF2 since roughly 2016. If your @font-face still has a four-format src stack with EOT and TTF fallbacks, you copied it from a 2014 blog post.
/* No. */
@font-face {
font-family: 'Inter';
src: url('inter.eot');
src:
url('inter.eot?#iefix') format('embedded-opentype'),
url('inter.woff2') format('woff2'),
url('inter.woff') format('woff'),
url('inter.ttf') format('truetype');
}
/* Yes. */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
Two related server-side notes:
- Don’t gzip or Brotli-compress WOFF2 at the server. WOFF2 is Brotli-compressed internally. Re-compressing burns CPU and typically makes the payload marginally larger. Check your nginx
gzip_types/ CDN config and make surefont/woff2isn’t in it. - Serve it as
font/woff2. Notapplication/octet-stream.
4. Variable fonts: a decision rule, not a rule
Variable fonts are frequently recommended unconditionally. They shouldn’t be. A variable font carries the full interpolation space, so it’s substantially larger than any single static instance — as a rough rule, a weight-axis variable font costs about the same as two to three static weights of the same family.
The arithmetic is straightforward:
| You use | Better choice |
|---|---|
| 1–2 weights | Static instances |
| 3+ weights | Variable |
| Any genuinely fluid axis (weight animation, optical sizing tied to viewport) | Variable, regardless of count |
If you go variable, declare the range so the browser can do the right thing:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
Plain format('woff2'). Not format('woff2-variations') — that was a transitional syntax from the early variable-font era and it’s been superseded twice since; the spec’s replacement is format('woff2') tech('variations'). You need neither. Any browser that understands variable fonts understands a variable font served as WOFF2, and the font-weight: 100 900 range is what actually tells it the axis exists. If you want a live reference, look at what Google Fonts itself emits for a variable family today: format('woff2'), nothing else.
And audit what you actually use before deciding. Designers specify six weights; production sites usually render two. A quick pass over your compiled CSS for font-weight declarations is often clarifying.
rg -o --no-filename 'font-weights*:s*[^;}]+' <path> -g '*.css' | sed 's/^font-weights*:s*//' | sort -u
5. Subsetting
This is where the real wins are. A full Latin-plus-Cyrillic-plus-Greek font with complete punctuation and every OpenType feature might be 300 KB. The glyphs an English-language marketing site actually renders might be 15 KB.
The primitive: pyftsubset
pyftsubset ships with fonttools (pip install fonttools brotli).
pyftsubset Inter-Regular.ttf
--output-file=inter-latin-400.woff2
--flavor=woff2
--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"
That unicode range is the one Google Fonts currently uses for its latin subset: basic Latin, Latin-1 Supplement, a handful of combining marks, common punctuation, currency, a few arrows, plus the BOM and the replacement character.
The flag people break their fonts with
Note what the command above doesn’t pass: --layout-features.
That flag controls which OpenType features survive subsetting, and the received wisdom about it is backwards. fontTools’ default is not a conservative set — it’s the union of HarfBuzz’s shaper groups. Ask it yourself:
python3 -c "from fontTools.subset import Options; print(' '.join(sorted(Options().layout_features)))"
BUZZ Buzz HARF Harf abvf abvm abvs akhn blwf blwm blws calt ccmp cfar chws cjct
clig cswh curs dist dnom fin2 fin3 fina frac half haln halt init isol jalt kern
liga ljmo locl ltra ltrm mark med2 medi mkmk mset nukt numr pref pres pstf psts
rand rclt rkrf rlig rphf rtla rtlm rvrn stch tjmo valt vatu vchw vert vhal vjmo
vkrn vpal vrt2
Kerning, ligatures, contextual alternates, mark positioning, localized forms, and the complete Arabic, Indic, Hangul and Tibetan shaping sets — already on.
So this line, which you will find copy-pasted across the web font internet, is a downgrade:
--layout-features='kern,liga,clig,calt' # replaces the default. don't.
Bare = replaces the set. That command silently drops mark and mkmk (mark positioning — stacked diacritics and Vietnamese tone marks drift out of place), ccmp (glyph composition), locl (localized forms — Serbian Cyrillic italics, Romanian comma-below), and every complex-script feature in the font. §10 is what that last part looks like in production.
If you genuinely need something outside the default — a stylistic set, discretionary ligatures — add to it:
--layout-features+='ss01,dlig'
unicode-range: let the browser decide
Rather than one file per language, declare multiple @font-face blocks with the same font-family and different unicode-range. The browser downloads only the subsets whose codepoints actually appear on the page.
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-400.woff2') format('woff2');
font-weight: 400;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-cyrillic-400.woff2') format('woff2');
font-weight: 400;
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
font-display: swap;
}
An English page downloads the first file and never touches the second. A Russian page does the reverse. No server-side logic, no build-time branching.
Automating it
- glyphhanger crawls your rendered pages, collects the codepoints actually used, and emits subsets plus the matching
unicode-rangestring. - subfont goes further — it rewrites your HTML and CSS to point at the subsets it generates.
The failure mode
Aggressive per-page subsetting based on observed glyphs breaks the moment content changes. A user posts a comment with an em-dash you didn’t subset, and they get tofu (□) — or worse, a silent fallback that only affects that one character, which looks like a rendering bug.
Rule of thumb: subset by script, not by observed glyphs, anywhere user-generated or CMS-editable content appears. Save the aggressive per-page approach for genuinely fixed text — a landing page headline, a logotype.
6. font-display: choose your poison
There is no correct value. Each one trades a different thing away.
The browser divides the load into a block period (text invisible, space reserved) and a swap period (fallback shown, will swap when the font arrives).
| Value | Block | Swap | You get | You pay |
|---|---|---|---|---|
auto | ~3s* | ∞ | whatever the browser decides | invisible text (FOIT) |
block | ~3s | ∞ | no fallback flash | up to 3s of invisible text |
swap | ~100ms | ∞ | text visible almost immediately | guaranteed reflow when it swaps (FOUT) |
fallback | ~100ms | ~3s | swap early or not at all | inconsistent rendering between visits |
optional | ~100ms | none | near-zero CLS | font may simply not appear on first visit |
* auto is defined as “browser’s choice,” not as a timing. In practice Chromium and Firefox treat it as block, which is why omitting font-display gives you FOIT by default. Don’t omit it.
swap is the common default and it’s a defensible one — text is readable fast — but it guarantees a layout shift on every cold load unless you do §7.
optional is the CLS-optimal choice and underused. The browser gives the font ~100ms; if it doesn’t arrive, the fallback is used for that entire page view and the font is cached for next time. First visit may show no custom font at all. For body copy on a content site, that’s often a fine trade. For a brand landing page, it usually isn’t.
A reasonable split:
- Body text →
optionalorswap+ metric overrides - Headings, brand type →
swap+ metric overrides - Icon fonts →
block(a tofu box is worse than a brief gap — though consider inline SVG instead)
7. Metric-matched fallbacks: the section that actually matters
Most articles end at font-display: swap and call it done. But swap means the browser renders in a fallback and then re-renders in your font — and if the two have different metrics, every line of text moves. That’s a Cumulative Layout Shift hit on every cold page load, and it’s the jank users actually notice.
The fix: declare a @font-face for the fallback — pointing at a locally installed font — and override its metrics to match your web font.
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter Fallback', sans-serif;
}
Now Arial renders at adjusted proportions that occupy the same space Inter will. The swap becomes a change of shape, not a change of layout. CLS goes to approximately zero while text stays visible from the first paint — the tradeoff swap normally forces on you, mostly dissolved.
(Those four numbers are per font pair. Don’t copy mine; generate yours — see below.)
Safari implements one quarter of this
The part every article skips. Support splits cleanly:
| Descriptor | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
size-adjust | 92+ | 92+ | 17+ |
ascent-override, descent-override, line-gap-override | 87+ | 89+ | not implemented |
Safari — including current versions — scales the fallback and ignores the vertical corrections entirely. Whether that costs you anything depends on one thing: whether you set an explicit line-height.
- Unitless
line-height(1.6,1.5) — the line box height comes from your CSS, not from the font’s ascent and descent.size-adjustmatches the glyph widths, so wrapping and line count hold, and Safari lands close enough. This is the common case, and it’s fine. line-height: normal— the line box is derived from the font’s own metrics. Safari scales the fallback without correcting them, which can produce a larger shift than doing nothing at all.
You can’t feature-detect your way out of it directly: @supports tests properties, and these are @font-face descriptors. The workaround is an indirect proxy for “Chromium or Firefox, not Safari”:
@supports (overflow-anchor: auto) {
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
}
Safari then never sees the Inter Fallback family at all, so the stack falls through to sans-serif — no scaling, no correction, exactly where you were before.
Set an explicit line-height instead if you can. Less clever, no browser-sniffing by proxy, and it fixes the underlying cause.
Deriving the numbers
You can compute them from the font’s head and hhea/OS/2 tables, but there’s no reason to do it by hand:
- Fontaine — a build plugin (Vite, Webpack, Nuxt) that reads your font files and injects the overrides automatically. This is what I’d reach for; Part 2 covers wiring it up. It emits all four descriptors, including the three Safari drops on the floor — which is the right call, it just means Safari’s result is the
size-adjust-only one described above. - Fallback Font Generator — browser tool, good for one-offs and for understanding what the numbers mean.
- capsize — lower-level, if you want to compute metrics yourself.
One caveat: local() resolves against fonts installed on the user’s machine, and the OS defaults differ. Arial isn’t reliably present on Linux; Roboto isn’t on Windows. Pick a fallback stack per platform, or accept that the overrides are approximate on the long tail. Approximate metric matching still beats none.
8. Preload — and the crossorigin trap
Self-hosting and subsetting shorten the chain. Preloading skips a link in it: it tells the browser to start fetching the font at HTML-parse time, without waiting for CSS parsing and layout.
<link rel="preload" href="/fonts/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin />
Three things to get right, and most people miss the third:
rel=preload, not rel=prefetch. These are unrelated. prefetch is a low-priority speculative fetch for a future navigation — the browser may do it during idle time, or not at all. It’s the wrong hint for a resource the current page needs. (preconnect is a distant third option: useful if you’re still on a third-party origin, unnecessary once you’ve self-hosted.)
as=font sets the correct priority and Accept header.
crossorigin is mandatory — even same-origin. This is the bug I see most often. Fonts are always fetched in anonymous CORS mode, per spec, regardless of origin. A preload without crossorigin is issued in a different mode than the eventual real request, the preload cache doesn’t match, and the browser downloads the file twice — making things strictly worse than not preloading. Note that crossorigin with no value is anonymous, which is what you want.
Chrome logs a console warning when a preload goes unused. Check for it after adding one; it’s the fastest way to catch this.
Don’t preload everything
Preloads compete with each other and with your CSS for bandwidth. Preload the one or two fonts visible in the initial viewport — usually a body regular and maybe a heading weight. Preloading six weights delays all six and your stylesheet.
One interaction worth naming: unicode-range splits from §5 mean the browser fetches only the subsets a page needs, but a preload is unconditional. Preload the latin subset on a Latin page and you’ve helped. Preload all five subsets and you’ve just downloaded four files the page will never draw a glyph from, at high priority, ahead of your CSS.
9. Cache headers
Fonts change approximately never. Give them a content hash in the filename and cache them forever:
Cache-Control: public, max-age=31536000, immutable
immutable matters: without it, browsers still send a conditional revalidation request on reload. With it, they don’t. A 304 is cheap but it’s still a round trip.
This requires hashed filenames (inter-latin-400.a3f9c1.woff2) so a font change produces a new URL. Most bundlers do this for assets they process — Part 2 covers the SvelteKit specifics, including why the hashing interacts awkwardly with the preload tag from §8.
If you’re serving fonts from a different subdomain than your pages, you also need Access-Control-Allow-Origin, because of the anonymous-CORS rule from §8. Simplest fix: same origin.
10. Non-Latin scripts: the trap nobody writes about
Everything in §5 assumed that subsetting means “keep these codepoints, drop the rest.” For Latin, that’s roughly true. For Arabic, Persian, Urdu, and the Indic scripts, the assumption produces broken text rather than missing text — and the break is quiet.
Arabic letters change shape by position — initial, medial, final, isolated. The word for “book,” كتاب, contains ك ت ا ب, but no letter renders in the form you’d see in isolation. Those positional forms are separate glyphs in the font, reached through OpenType layout tables (GSUB for substitution, GPOS for positioning). They have no Unicode codepoints of their own.
Which means a codepoint list can’t reach them. The subsetter only keeps them because it walks the layout features you told it to preserve and pulls in every glyph they can produce. Keep init, medi, fina and isol, and the variants come along. Drop those features, and you keep ك and throw away every shape it actually renders as.
Here’s the good news and the trap in one sentence: fontTools’ default feature set already includes the Arabic and Indic shaping groups, so a plain pyftsubset --unicodes=U+0600-06FF is fine — until someone pastes in the Latin-flavored --layout-features='kern,liga,clig,calt' from a blog post and replaces the default with four features that have nothing to do with shaping.
The result of that is not tofu. It’s text rendered in disconnected, isolated letterforms — legible-ish to a machine, visibly broken to a reader, and completely invisible in a diff. If you’re subsetting by observed glyphs (the aggressive approach from §5), you get the same outcome from the other direction: your crawler collected the codepoints in the source text, which by definition excludes the positional variants.
pyftsubset NotoSansArabic-Regular.ttf
--output-file=noto-arabic-400.woff2
--flavor=woff2
--layout-features='*'
--unicodes="U+0600-06FF,U+0750-077F,U+08A0-08FF,U+FB50-FDFF,U+FE70-FEFF"
Two things there:
--layout-features='*'keeps everything, including the stylistic and justification features outside the default set. For a script you don’t read, buy the extra kilobytes.- The unicode ranges include the Arabic Presentation Forms blocks (U+FB50–FDFF, U+FE70–FEFF). Some text sources — particularly older content and some PDF extractions — encode presentation forms directly instead of relying on the shaping engine. Including them costs a little size and prevents a confusing class of bug.
The same class of problem applies to Devanagari, Bengali, Tamil, and the other Indic scripts, where conjunct consonants are formed through substitution rules; and to Thai and Khmer, where mark positioning depends on GPOS. Rule: for any complex script, never narrow the layout features, and test with real text. Not lorem ipsum, not a single word — a paragraph, read by someone who reads the script.
Worth saying plainly: this is a scenario where visual inspection by a non-reader is insufficient. Disconnected Arabic looks like a font to someone who doesn’t read Arabic. It looks like a bug to everyone who does.
11. Checking you actually fixed something
Four checks, ordered by how often each one catches a real bug. All of them are DevTools, none of them take a minute.
1. Network panel, filtered to Font, cache disabled. Two rows for the same file is the crossorigin bug from §8. Any row pointing at fonts.gstatic.com means something still references Google. More rows than weights you use means the §4 audit is overdue.
2. Console. The resource <url> was preloaded using link preload but not used within a few seconds is Chrome telling you the preload didn’t match the real request, or that you preloaded a subset the page never needed.
3. Performance panel, “Experience” track. Record a cold load on a throttled connection. You want no layout-shift marker at the moment the font swaps in. If there is one, the overrides from §7 aren’t landing — check Safari separately, it will behave differently.
4. What actually loaded, in the console after load:
[...document.fonts]
.filter((f) => f.status === 'loaded')
.map((f) => `${f.family} ${f.weight} ${f.style}`);
If the design calls for two faces and this prints five, that gap is the whole optimization, measured.
Part 2 wires all of this into a SvelteKit project, including the awkward bit: how to preload a font whose filename Vite hashes at build time.
