Variable Fonts vs Static Weights: A Decision Guide
This comparison is part of the Variable Font Loading Techniques guide, which sits under the broader Font Loading & Delivery Strategies blueprint. The decision is narrow but consequential: ship one variable .woff2 that carries every weight, or ship a handful of static subsets cut to the exact weights you use. The wrong choice either wastes 80–120KB of transfer on axes you never render, or fragments your typography across redundant requests that delay LCP.
Problem Statement
A variable font packs a continuous weight axis (wght, typically 100–900) into a single file. That file is bigger than any one static weight — usually 50–150KB compressed as WOFF2 — because it stores the master outlines plus the delta data the rasterizer interpolates between. A static instance, by contrast, stores one frozen weight and subsets to roughly 15–30KB after pyftsubset. So the trade is fixed overhead (one larger file, one request) versus marginal cost (one smaller file per weight, one request each). The break-even point is not intuitive, and teams routinely ship a 140KB variable font to render only Regular and Bold — paying for seven weights they never reference.
Prerequisites
Before measuring, confirm the baseline is sound:
- WOFF2 assets are served with
Content-Type: font/woff2andCache-Control: public, max-age=31536000, immutable. - Each
@font-facecarriesfont-display: swap(oroptionalfor hero text) so neither approach hides text during the fetch. - You have an accurate inventory of the weights your design system actually paints above the fold — not the weights the brand guide lists. Audit computed
font-weightvalues in DevTools, not the Figma file. - A subsetting tool (
pyftsubsetfrom fonttools, orglyphhanger) is available to cut static instances and to strip unused axes/glyphs from the variable file.
Implementation: The Variable @font-face
When three or more weights are in play, declare one variable file with a continuous font-weight range. The browser interpolates every intermediate value from the single cached file, so a component using font-weight: 540 costs no extra request.
Variable @font-face with weight range and axis control
@font-face {
font-family: 'InterVar';
src: url('/fonts/inter-roman-subset.woff2') format('woff2-variations');
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+2018-2019, U+201C-201D;
}
:root {
font-optical-sizing: auto;
}
.body { font-family: 'InterVar', system-ui, sans-serif; font-weight: 400; }
.lead { font-family: 'InterVar', system-ui, sans-serif; font-weight: 540; }
.bold { font-family: 'InterVar', system-ui, sans-serif; font-weight: 700; }
.black { font-family: 'InterVar', system-ui, sans-serif; font-weight: 900; }
The font-weight: 100 900 declaration is the load-bearing line — it tells the CSS engine the file is variable and which range to interpolate. Use format('woff2-variations') (or plain woff2; both work in current engines) so the browser fetches once. Prefer the standard font-weight property over hard-coding font-variation-settings: 'wght' 540, because the latter resets all axes to their defaults and disables font-synthesis, breaking the cascade. Reach for font-variation-settings only when you need a non-registered axis. Subset the variable file too: stripping the ital/slnt masters and unused glyph ranges with pyftsubset --flavor=woff2 --layout-features='*' --drop-tables+=... routinely takes a 280KB raw variable font down to 80–110KB.
The Static Multi-Weight Equivalent
When you need only one or two weights, static instances win decisively. Each subset is a quarter the size of the variable master, and you ship exactly what you render.
Static multi-weight equivalent (two weights)
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-regular-subset.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-bold-subset.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
Two subsets at ~22KB each total ~44KB — well under a single 100KB variable file. But note the cost curve: every additional weight adds a full file and a full request. By the time the design system uses Regular, Medium, Semibold, Bold, and Black, you are at five files and roughly 110–130KB, and HTTP/2 request overhead plus cache fragmentation start to bite. That is the crossover.
Quantifying the Crossover
The arithmetic is straightforward once you fix the per-file sizes. Take a subset static instance at ~22KB and a subset variable file at ~95KB:
| Weights rendered | Static total (≈22KB each) | Variable total | Winner |
|---|---|---|---|
| 1 | ~22KB | ~95KB | Static (4.3× smaller) |
| 2 | ~44KB | ~95KB | Static (2.2× smaller) |
| 3 | ~66KB | ~95KB | Static, but margin narrowing |
| 4 | ~88KB | ~95KB | Roughly even — crossover |
| 5 | ~110KB | ~95KB | Variable |
| 6+ | ~132KB+ | ~95KB | Variable (and fewer requests) |
The crossover lands at three to four weights. Below it, static subsets transfer less and start painting sooner because the first file is small. At four it is a wash on bytes, and the tiebreaker becomes request count and continuity: variable wins if you also use intermediate weights (e.g. 540, 620) or animate the axis, since static can only step between the weights you shipped. Above four weights, variable is smaller and a single request, which matters on a cold HTTP/2 connection where each static file pays its own header and priority-scheduling overhead.
Decision Matrix
| Factor | Lean static subsets | Lean variable font |
|---|---|---|
| Weights needed | 1–3 distinct weights | 4+ weights, or any intermediate value |
| Axes used | wght only, fixed steps |
wght + opsz/wdth/slnt, or animated axis |
| Payload | Lowest when ≤3 weights (~22KB each) | Lowest when ≥5 weights (~95KB flat) |
| Browser targets | Must support legacy engines (IE11) | Variable fonts fine in all evergreen browsers |
| Request count | One per weight (HTTP/2 overhead grows) | Single request, single cache entry |
| Motion / fluid weight | Not possible — only shipped steps | Native; animate wght smoothly |
| Above-the-fold preload | Preload each critical weight | Preload one file |
Connect the matrix to delivery: whichever side wins, preload only the above-the-fold weight using resource hints, and split glyphs across unicode-range subsets so the browser fetches only the ranges the page renders.
Verification
Confirm the choice paid off in the Network panel, not in theory.
- Open Chrome DevTools → Network → Filter: Font. Reload with cache disabled.
- Read the Size column (transfer size over the wire, not the larger decompressed size). Sum the font rows.
- For the variable approach, you should see exactly one
.woff2row regardless of how many weights the page uses. For static, you should see one row per weight — count them and verify none are weights you do not actually render. - Check the Initiator column shows your preload link for the critical file, and that no font row appears twice (a double-fetch signals a missing
crossorigin). - Cross-check against your font budget in Lighthouse; a CI budget of <120KB total font transfer flags regressions when someone adds a fourth static weight that should have triggered the switch to variable.
Common Pitfalls
- Shipping a variable font for two weights. Paying ~95KB to render Regular and Bold wastes ~50KB versus two ~22KB subsets. Audit actual rendered weights before defaulting to variable.
- Forgetting to subset the variable file. A raw variable font can be 250–350KB. Strip unused axes and glyph ranges with
pyftsubset; an unsubset variable font loses to static every time. - Using
font-variation-settingsfor plain weight. It overrides the high-levelfont-weight, resets every other axis to default, and disablesfont-synthesis— breaking bold/italic fallback. Use the standardfont-weightrange instead. - Declaring discrete
@font-faceblocks pointing at the same variable file. This can trigger duplicate downloads and fragments the cache; use one block with afont-weightrange. - Ignoring intermediate weights in the count. If your design uses
540or animates the axis, static cannot express it at any file count — variable wins regardless of the byte math.
FAQ
At how many weights should I switch from static subsets to a variable font? The byte crossover is around three to four weights, assuming ~22KB static subsets and a ~95KB subset variable file. At four weights it is roughly even on transfer size, so let the tiebreakers decide: choose variable if you also use intermediate weights, animate the axis, or want a single request and cache entry; choose static if you render only the shipped steps and want the smallest first paint.
Does a variable font hurt LCP because the first file is larger?
It can, because the single 95KB file must download before any weight paints, whereas a small 22KB Regular subset arrives sooner. Mitigate by pairing either approach with font-display: swap so fallback text renders immediately, and by preloading the critical file. When only Regular drives the LCP element, a static subset usually paints first.
Can I serve a variable font to modern browsers and static to old ones?
Yes — use a @supports (font-variation-settings: 'wght' 400) block or progressive src lists so evergreen browsers fetch the variable file and legacy engines fall back to a static weight. In practice variable fonts are supported across all evergreen browsers, so this fork is only worth the complexity if you must support IE11.