Loading Variable Fonts with Font-Weight Ranges: Optimization & Implementation

This deep-dive is part of the Variable Font Loading Techniques guide within the Font Loading & Delivery Strategies blueprint. If you have not yet committed to a variable file, the variable fonts vs static weights decision guide frames that trade-off before you write any CSS.

Problem Statement

A variable font carries every weight on a continuous wght axis inside one file, yet teams routinely declare it the way they declared static fonts: a separate @font-face block per weight, each pointing at the same .woff2. That single mistake either triggers duplicate downloads, fragments the HTTP cache, or — when components request a weight no block names — forces the browser to fall back mid-paint and shift the layout. The symptom is a Cumulative Layout Shift (CLS) spike late in load plus a redundant font request in the Network panel. The fix is a single @font-face whose font-weight is declared as a range (e.g. 100 900), letting the CSS engine interpolate any value in that span from one cached file.

The central mechanism is the mapping between a continuous font-weight range in @font-face and the wght axis the browser drives through font-variation-settings. A single declared range lets the CSS engine resolve font-weight: 540 to 'wght' 540 by interpolation, with no extra fetch.

font-weight range mapping to the wght variation axis A font-weight 100 to 900 range in font-face maps onto the wght axis, and a CSS font-weight of 540 resolves to font-variation-settings wght 540 via interpolation. @font-face font-weight: 100 900; wght 100 900 wght 540 font-variation-settings: 'wght' 540 CSS font-weight: 540
The declared font-weight range defines the wght axis span; any CSS weight inside it interpolates without an extra fetch.

Prerequisites

Before declaring the range, confirm the delivery baseline is correct, or the range syntax will mask a deeper problem:

  • A genuine variable .woff2 with a wght axis. Verify with fc-query or by opening the file in fonttools ttx -t fvar and confirming an <Axis> entry for wght with min/max values. A static instance declared 100 900 will simply clamp.
  • WOFF2 served with Content-Type: font/woff2 and Cache-Control: public, max-age=31536000, immutable. Missing immutable re-validates on repeat visits and inflates TTFB.
  • Correct CORS for cross-origin or preloaded fonts: the server must send Access-Control-Allow-Origin, and the <link rel="preload"> must carry crossorigin. A mismatch yields an opaque response that the CSS engine cannot reuse, causing a second fetch.
  • A font-display value chosen per text role — swap for body, optional for hero text — so fallback text paints immediately while the variable file downloads.

Implementation: The Range @font-face

Declare one @font-face and express font-weight as a two-value range. The browser caches the single file and synthesizes every intermediate weight your components request — 400, 540, 620, 700 — without another request.

Single variable @font-face with a continuous weight range

@font-face {
  font-family: 'InterVariable';
  src: url('/fonts/inter-variable.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;
}

.body { font-family: 'InterVariable', system-ui, sans-serif; font-weight: 400; }
.lead { font-family: 'InterVariable', system-ui, sans-serif; font-weight: 540; }
.bold { font-family: 'InterVariable', system-ui, sans-serif; font-weight: 700; }

The font-weight: 100 900 line is load-bearing: the two-value syntax is what tells the CSS engine the family is variable and which span of the wght axis is valid. Any font-weight your selectors set inside that range resolves by interpolation from the one cached file — .lead { font-weight: 540 } costs zero extra bytes. format('woff2-variations') (or plain woff2; both parse in current engines) keeps the fetch to a single request. Pair the range with font-style: normal so the browser does not infer an italic axis and request a second face. Prefer this high-level font-weight over hard-coding font-variation-settings: 'wght' 540 on each selector: the low-level property resets every other axis to its default and disables font-synthesis, breaking the weight cascade. The optional unicode-range restricts the file to the glyphs the page paints, layering unicode-range subsetting on top of the variable saving. Critically, do not add a second @font-face for font-weight: 700 pointing at the same URL — that fragments the cache and can double-download the file.

Preload only the variable file that drives above-the-fold text, and only when it is render-critical:

Preload the critical variable file (crossorigin required)

<link rel="preload" href="/fonts/inter-variable.woff2" as="font" type="font/woff2" crossorigin fetchpriority="high">

The crossorigin attribute is mandatory even same-origin: fonts are always fetched in CORS mode, and a preload without it lands in a different cache partition than the CSS-initiated request, producing the duplicate fetch this page exists to prevent.

Timeout & Error-Handling Variant

font-display: swap already guarantees text is never invisible, but a robust system gates its "fonts loaded" UI transition on the CSS Font Loading API with a timeout, so a stalled font never blocks hydration or leaves a half-styled view.

Gate the loaded class on a timed font promise

function activateVariableFont(timeoutMs = 3000) {
  if (!('fonts' in document)) return;

  const ready = document.fonts.load('400 1em InterVariable')
    .then(() => document.fonts.load('700 1em InterVariable'));

  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('font-timeout')), timeoutMs));

  Promise.race([ready, timeout])
    .then(() => document.documentElement.classList.add('fonts-loaded'))
    .catch(() => document.documentElement.classList.add('fonts-failed'));
}

activateVariableFont();

document.fonts.load('400 1em InterVariable') returns a promise resolving when that weight is parsed and ready; chaining a second load for 700 confirms both ends of the range are usable before the class flips. Promise.race against a 3-second timeout means a slow network never pins the page in an unstyled state — on timeout the fonts-failed class lets CSS commit to the metric-matched fallback rather than waiting indefinitely. Because swap is set on the @font-face, the actual glyphs still swap in if they arrive after the timeout; the class only controls discretionary UI (for example, revealing a hero animation) that you do not want to start until type is settled. Never block framework hydration on document.fonts.ready without a timeout — a single dropped font request would otherwise hang the render path.

Verification

Confirm the single-file behavior in the Network panel rather than trusting the CSS:

  1. Open Chrome DevTools → Network → Filter: Font. Reload with cache disabled.
  2. Confirm exactly one .woff2 row appears, regardless of how many distinct font-weight values the page uses. Two rows for one file means a missing crossorigin or a stray second @font-face.
  3. Read the Size column (transfer size, not the larger decompressed size) and check it against your font budget.
  4. In Performance, record a load, open Main thread → Layout Shift events, and verify no shift is attributed to the font swap. Cross-reference with the Lighthouse "Avoid large layout shifts" audit.
  5. In the Elements → Computed pane, inspect a font-weight: 540 element and confirm the resolved weight interpolates rather than snapping to 500 or 700.
Symptom in DevTools Likely cause Fix
Two rows for one font URL Missing crossorigin, or duplicate @font-face Add crossorigin; collapse to one range block
Weight snaps to 400/700 File is a static instance, not variable Confirm wght axis in fvar; ship the variable file
CLS spike at font swap No metric-matched fallback Add fallback metric overrides
High TTFB on repeat visit Missing immutable cache header Set Cache-Control: ...immutable

Common Pitfalls

  • Discrete @font-face blocks for one file. Declaring 400 and 700 as separate blocks that point at the same variable .woff2 triggers duplicate downloads and fragments the HTTP cache. Use one block with a font-weight range.
  • Missing crossorigin on the preload. The preloaded response uses a different CORS mode than the CSS request, so it is not reused — you pay for a second fetch of the same file.
  • font-variation-settings for plain weight. Setting 'wght' 540 per selector overrides the high-level font-weight, resets all other axes to default, and disables font-synthesis, breaking bold/italic fallback. Drive weight through font-weight.
  • font-display: block on a variable file. It extends the FOIT block period to 3s and delays the LCP text paint. Use swap (or optional for hero text) instead.
  • Mismatched unicode-range and font-weight ranges. If a requested glyph falls outside the declared unicode-range, the browser fetches a fallback face mid-render, causing the very shift you are trying to remove.

FAQ

How does specifying a font-weight range affect browser caching?

The browser caches the single variable file once, independent of the declared range. The range only instructs the CSS engine which weights it may interpolate from that cached file, which is what prevents duplicate requests across components that render different weights. Pair it with Cache-Control: ...immutable so the cached entry survives repeat visits without revalidation.

Why does DevTools show two font requests for one variable file?

Almost always a missing crossorigin attribute on the <link rel="preload">. The preload fetches in one CORS mode and the CSS engine requests in another, so they hit different cache entries and the file downloads twice. Add crossorigin to the preload and confirm the server returns Access-Control-Allow-Origin.

Should I use font-display: swap or optional for a variable font?

Use swap for body text so glyphs are never invisible; the metric-matched fallback paints immediately and the variable face swaps in. Use optional for above-the-fold hero text where any swap-induced shift is unacceptable — if the file misses the ~100ms block window the browser keeps the fallback for that view, and the cached variable file renders instantly on the next visit.

Related