Subsetting and Preloading Variable Fonts

Your body font carries a <link rel="preload">, DevTools reports it at High priority with no queueing time, and text still swaps at 900 ms — because the file you promoted to the front of the queue is a 246 KB variable font holding 3,402 glyphs across two axes, and priority reorders a response without making it any smaller.

Root Cause: Priority Reorders the Queue, It Does Not Shrink the Response

A font preload does exactly two things. It moves discovery forward, letting the preload scanner issue the request during HTML tokenisation instead of after the stylesheet has been fetched and the CSSOM has matched a @font-face rule to a rendered glyph. And it sets the request’s destination to font, which lands it in Chromium’s High band inside the browser’s resource priority queues. Both effects are about scheduling. Neither touches the size of the response body, and once a request is already first in line, the only remaining variable is how many round trips the transfer needs.

That number is set by congestion control, not by the hint. A fresh TCP or QUIC connection opens with an initial window of ten segments — roughly 14.6 KB — and doubles each round trip while slow start lasts. Cumulative bytes deliverable after n round trips are therefore about 14.6 × (2ⁿ − 1) KB: 14.6 after one, 43.8 after two, 102 after three, 219 after four, 452 after five. A 246 KB body needs the fifth round trip; a 27 KB body finishes inside the second. On a 150 ms link that is 750 ms of transfer against 300 ms, before decoding, and the gap is pure protocol arithmetic that no priority signal can argue with.

Worse, on a multiplexed connection the oversized font is not merely slow — it is expensive to everyone else. HTTP/2 and HTTP/3 share one congestion window across all streams on the connection, so bytes granted to the font are bytes withheld from the LCP image sitting in the same High band. Preloading a fat variable font is one of the few ways to make an LCP image measurably slower by adding a hint, which is why the fix is always to shrink the file first and re-examine the hint second. The timeline below shows both effects on the same cold connection.

Cold-cache delivery timeline comparing a 246 KB variable font with a 27 KB subset on a 150 ms link, including the knock-on effect on the LCP image Two stacked timelines on a shared millisecond axis. In the before lane, 150 ms of time to first byte is followed by 750 ms of body transfer across five slow-start round trips, so the face is usable at 980 ms and the LCP hero image, sharing the same congestion window, completes at 1180 ms. In the after lane the 27 KB subset transfers in two round trips, the face is usable at 470 ms and the LCP image completes at 860 ms. Cold-cache delivery, 150 ms RTT, IW10 slow start, one shared connection Before — one 246 KB face font TTFB font body — 5 RTTs of slow start usable at 980 ms image LCP hero image — shares the High queue 1180 ms After — 27 KB Latin subset font TTFB font body — 2 RTTs usable at 470 ms image LCP hero image — full window to itself 860 ms 0 250 500 750 1000 1250 ms

Note what did not change between the two lanes: the hint, the priority, the connection, the server and the round-trip time. Only the payload moved, and it moved the swap 510 ms earlier and the LCP image 320 ms earlier along with it.

Where the Bytes Actually Live in a Variable Font

A variable font is a static font plus a delta machine. The glyf table stores the outlines of the default instance exactly as a static font would. Everything that makes the file variable lives in gvar, which stores, for every glyph, a set of point deltas per axis region — so its size scales with glyph count multiplied by axis count multiplied by the number of on-curve points per glyph. Metrics variation follows the same pattern in HVAR and MVAR, and STAT describes the axis model itself. On a typical two-axis text family, gvar alone is 40–45 % of the file, which means the bytes you are paying for are mostly the ones describing weights and widths you never set.

There are two independent levers, and they compose. Instancingfonttools varLib.instancer — narrows or pins axes: pinning wdth to 100 evaluates every delta at that width and then deletes the axis’s delta sets entirely. Subsettingpyftsubset — removes glyphs, which shrinks glyf, gvar, HVAR, cmap, GSUB and GPOS simultaneously, because all of them are indexed by glyph ID. Instancing first is cheaper: the subsetter then has fewer delta sets to rewrite, and the WOFF2 transform compresses a smaller gvar far better. The measured breakdown for a two-axis Latin UI family looks like this.

Stacked byte composition of a variable WOFF2 at three build stages: as exported, after pinning axes, and after subsetting codepoints Three stacked horizontal bars drawn to the same byte scale. The exported file totals 246 KB, of which gvar axis deltas are 108 KB and glyf outlines are 82 KB. Pinning the width and optical size axes cuts gvar to 46 KB for a 168 KB total. Subsetting from 3,402 glyphs to the 214 codepoints the page renders leaves 27 KB in total. WOFF2 table composition at each build stage — Latin UI variable font, 2 axes 1 · As exported 3,402 glyphs, wght+wdth gvar · 108 KB glyf · 82 KB 246 KB 2 · Axes pinned wdth=100, opsz=16 gvar · 46 glyf · 82 KB 168 KB gvar sheds 62 KB of unused deltas 3 · Codepoints cut 214 glyphs, Latin only 27 KB 89% smaller — 2 slow-start round trips instead of 5 gvar — axis deltas glyf — outlines HVAR / STAT GSUB / GPOS cmap / name

Read the third bar carefully: after subsetting, gvar is 9 KB — the weight axis survives, fully interpolable from 400 to 700, and costs almost nothing because only 214 glyphs carry deltas. Keeping variability is not what made the original file heavy; keeping 3,188 glyphs nobody rendered is.

Minimal Reproduction

The broken configuration is not exotic — it is what you get by downloading a foundry release, dropping it into /fonts/, and following the standard preload advice. Every line here is individually correct, which is why the symptom is so persistent.

<head>
  <!-- The hint is well formed: as="font" sets the High destination and
       crossorigin matches the CORS-anonymous mode every font fetch uses.
       It buys ~250 ms of earlier discovery and then stops helping, because
       the 246 KB body still needs five slow-start round trips to arrive. -->
  <link rel="preload" href="/fonts/Inter-VF.woff2" as="font"
        type="font/woff2" crossorigin>
  <style>
    @font-face {
      font-family: 'Inter';
      /* The full foundry release: wght 100–900, wdth 75–125, 3,402 glyphs
         covering Latin, Greek, Cyrillic and Vietnamese. The page renders
         214 distinct codepoints at three weights. */
      src: url('/fonts/Inter-VF.woff2') format('woff2');
      font-weight: 100 900;
      font-display: swap;
    }
    body { font-family: 'Inter', system-ui, sans-serif; }
  </style>
</head>

Load that with the cache disabled on a throttled Slow 4G profile and the Network panel tells the whole story: Priority High, Queueing 0 ms, Content Download 750 ms, transferSize 246 KB. The queueing column is what makes engineers keep re-reading the hint — there is nothing wrong with the scheduling. The bytes are the bug.

Deciding Which Faces Earn a Preload Hint

Once the build emits several files instead of one, the second failure mode appears: preloading all of them. A unicode-range fetch is conditional — the browser downloads the Cyrillic subset only if a codepoint in U+0400-04FF is actually laid out. A preload is unconditional: it fetches the file immediately, whether or not any rule ever matches. Preload every subset and you have re-created the original payload while also earning a preloaded-but-not-used console warning for each file the page did not render.

The same logic applies to weights and styles. A preloaded italic face that only appears inside a rarely-visited blockquote is 14 KB stolen from the LCP image at exactly the wrong moment. Where a secondary face genuinely must be hinted, mark it fetchpriority="low" rather than dropping it into the High band beside the resource that decides your LCP. Run each generated file through this test before it gets a <link>.

Decision tree determining which generated font files earn a preload hint, based on first-viewport rendering, unicode-range certainty, and subset size A three-question decision tree. If the face is not rendered in the first viewport, it gets no preload and the unicode-range descriptor gates the request. If it is rendered but the unicode-range is not certain to match, a preload would go unused. If the file is not yet instanced and subset below 40 KB, fix the build first. Only a face that passes all three questions receives a link rel=preload with as=font, type=font/woff2 and crossorigin. Which generated font files earn a preload hint Rendered in the first viewport? body, headings, LCP text block No preload — fetch on demand unicode-range gates the request no yes unicode-range certain to hit? Latin yes, Cyrillic usually no No preload — it would go unused console logs a not-used warning no yes Pinned and under 40 KB? instanced + codepoint-subset Fix the build, then re-ask pin axes, cut codepoints, WOFF2 no yes Exactly one face should reach this branch on most pages. Preload it as=font · type=font/woff2 · crossorigin Everything answering no still ships in the CSS — it is fetched only when a glyph in its unicode-range is rendered.

Deterministic Fix Protocol

  • [ ] 1. Inventory the codepoints and weights you actually render. Walk the built HTML, the CSS content values and any JSON copy bundle, and collect the distinct codepoints plus the weights the design tokens reference. Record the number — you will use it to justify the --unicodes range in review, and a jump from 214 to 900 codepoints is the earliest signal that someone pasted a language switcher into the layout.
  • [ ] 2. Pin every axis you do not vary. Run varLib.instancer to keep only the weight range in use and pin wdth, opsz and slnt to their design values. This is the single largest win because it deletes whole delta sets from gvar and HVAR rather than trimming them.
  • [ ] 3. Subset by codepoint into WOFF2 in the same build step. Feed the instanced TTF to pyftsubset with an explicit --unicodes list and --flavor=woff2. Do not subset the foundry file directly — instancing afterwards is not possible once the axes are gone, and subsetting first leaves the subsetter rewriting deltas it is about to discard.
  • [ ] 4. Emit one file per script block and declare unicode-range. Latin, Latin-Extended, Greek and Cyrillic each become their own @font-face with a matching unicode-range, so a page of English prose never touches the Cyrillic file. Keep the ranges disjoint; overlapping ranges make the browser download both files for a codepoint in the intersection.
  • [ ] 5. Preload exactly one face, and byte-match its URL. The primary Latin subset gets a single <link rel="preload" as="font" type="font/woff2" crossorigin>. The href must match the @font-face src character for character — a differing query string, a protocol-relative form or a CDN redirect creates a second cache entry and a duplicated download.
  • [ ] 6. Deprioritise or drop hints on secondary faces. Italic, display and icon faces either get no hint at all or fetchpriority="low". Verify in the Network panel that only one font row reads High during the first 1,000 ms.
  • [ ] 7. Fingerprint the filename and cache immutably. Serve each subset with a content hash in the path and Cache-Control: public, max-age=31536000, immutable, so repeat views cost zero bytes and a subset regression cannot be masked by a stale cached copy during testing.
  • [ ] 8. Assert the transferred size in CI. Read encodedBodySize from the Resource Timing entry and fail the build above a threshold — 40 KB for the primary face is a workable ceiling. A size budget is the only defence against a designer re-exporting the full family six months from now.

Steps 2 and 3 are one shell block in practice. Both tools ship with fonttools:

# Step 2 — instance: keep the weight axis as a live range (the design system
# really does use 400/600/700 and interpolates on hover), and pin the axes it
# never touches. Pinning deletes their delta sets from gvar outright, which is
# why this runs BEFORE subsetting: fewer deltas for the subsetter to rewrite.
fonttools varLib.instancer Inter-VF.ttf \
  wght=400:700 wdth=100 opsz=16 \
  -o Inter-wght.ttf

# Step 3 — subset: drop every glyph outside the ranges the page renders. Because
# glyf, gvar, HVAR, cmap, GSUB and GPOS are all indexed by glyph ID, one pass
# shrinks all of them. --layout-features keeps kerning, standard ligatures,
# contextual alternates and tabular figures; dropping the rest saves ~3 KB in
# GSUB/GPOS without changing how any rendered string looks.
pyftsubset Inter-wght.ttf \
  --output-file=inter-latin.woff2 \
  --flavor=woff2 \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+2000-206F,U+20AC,U+2122,U+2212" \
  --layout-features="kern,liga,calt,tnum" \
  --no-hinting

--no-hinting removes the TrueType instructions along with prep, fpgm and cvt. That is safe wherever text is rasterised by a subpixel-positioning engine — Chromium on macOS, Android and modern Windows — and is worth roughly 8 % of the remaining file. Keep the hints if you still support legacy GDI rendering paths.

The corrected head declares each subset with its range and hints only the one that always matches:

<head>
  <!-- One hint, one face. as="font" puts it in the High band; crossorigin is
       mandatory even same-origin because font fetches are always CORS-anonymous,
       and an href that differs from the src below by even a query string would
       populate a second cache entry and download the file twice. -->
  <link rel="preload" href="/fonts/inter-latin.a91f3c.woff2" as="font"
        type="font/woff2" crossorigin>
  <style>
    @font-face {
      font-family: 'Inter';
      src: url('/fonts/inter-latin.a91f3c.woff2') format('woff2');
      /* The weight axis survives subsetting; clamp the declared range to what
         the instancer kept so a stray font-weight: 900 cannot silently fall
         back to the system font. */
      font-weight: 400 700;
      font-display: swap;
      unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122, U+2212;
    }
    @font-face {
      font-family: 'Inter';
      src: url('/fonts/inter-cyrillic.7b20de.woff2') format('woff2');
      font-weight: 400 700;
      font-display: swap;
      /* No preload: this request is issued only if a codepoint in the range is
         actually laid out, so hinting it would download 19 KB on every English
         page and log an unused-preload warning. */
      unicode-range: U+0301, U+0400-045F, U+0490-0491, U+2116;
    }
    body { font-family: 'Inter', system-ui, sans-serif; }
  </style>
</head>

Verification is a two-line Resource Timing read, which is also what step 8 asserts in CI:

// initiatorType is 'link' for a preloaded face and 'css' for one discovered
// through @font-face, so listing both catches the duplicate-fetch case: the
// same URL appearing twice means the preload href and the src did not match.
performance.getEntriesByType('resource')
  .filter((e) => e.name.endsWith('.woff2'))
  .forEach((e) => console.log(
    e.name.split('/').pop(),
    e.initiatorType,                     // link = preloaded, css = discovered late
    `${(e.encodedBodySize / 1024).toFixed(1)} KB`,
    `${Math.round(e.responseEnd - e.startTime)} ms`,
  ));

Before/After Metrics

Measured on a throttled Slow 4G profile (1.6 Mbps down, 150 ms RTT, cold cache) against a page whose LCP element is a hero image sharing the same HTTP/2 connection. The before column preloads the full 246 KB release plus a 31 KB italic face; the after column preloads one 27 KB Latin subset.

Metric Before After Change
Primary face transfer (encodedBodySize) 246 KB 27 KB −89%
Slow-start round trips for the body 5 2 −3
Font bytes fetched before first paint 277 KB 27 KB −250 KB
Face usable (decode complete) 980 ms 470 ms −510 ms
LCP image responseEnd 1180 ms 860 ms −320 ms
LCP 1240 ms 910 ms −330 ms
Font rows at High priority in the first second 2 1 −1
“Preloaded but not used” warnings 1 0 resolved
Repeat-view font bytes 0 0 unchanged

The LCP row is the one to take to a review. Nothing about the image changed — no new hint, no different format, no fetchpriority — yet it lands 320 ms sooner purely because 250 KB of font stopped competing for the same congestion window. First Contentful Paint barely moves in either column, because font-display: swap was already painting fallback text at ~690 ms; what improves is how long the reader spends looking at the fallback, which is the metric the swap-window guide treats in depth. Pair the subset with the fallback metric overrides described on the parent topic and the swap becomes both short and shift-free.

FAQ

Does subsetting a variable font destroy the weight axis?

No — subsetting and instancing are separate operations, and only one of them touches axes. pyftsubset removes glyphs and rewrites every glyph-indexed table, but it keeps fvar, STAT and the surviving glyphs’ entries in gvar, so the subset still interpolates across whatever axis ranges it declares. In the breakdown above, the 27 KB file is still a variable font with a live 400–700 weight axis; its gvar is small because only 214 glyphs carry deltas. varLib.instancer is the tool that narrows or removes an axis, and it only does so for the axes you name on the command line.

Should every unicode-range subset get its own preload hint?

No. The two mechanisms have opposite contracts: unicode-range makes a fetch conditional on a codepoint actually being laid out, while a preload issues the request unconditionally at parse time. Hinting all six subsets re-creates the payload you just eliminated, and the five that never match also fire an unused-preload warning three seconds later. Preload the primary Latin subset — the one every page in every locale renders — and let the descriptors gate everything else. The exception is a locale-routed site that renders Cyrillic on every page for those users: there, emit the hint server-side for the subset that locale will certainly need, and only that one.

Is one variable font always smaller than two static weights?

Not after subsetting, and the crossover is closer than people expect. At 214 glyphs the variable subset costs about 12 KB of outlines plus 9 KB of weight deltas plus 6 KB of shared tables; two static instances cost roughly 11 KB each with their own copies of cmap, GSUB and GPOS. So two weights favour the static pair by a few kilobytes, three weights are a wash, and four or more clearly favour the variable file — and only the variable file can interpolate an in-between weight for a hover or animation. Build both branches once and compare encodedBodySize; the answer depends on your glyph count and layout-feature set, not on a general rule.


Related