Web fonts that stopped blocking the render

The largest contentful paint on the product page was 2.9 seconds at the 75th percentile on mobile, and six hundred milliseconds of it was a font. The text was invisible while the font loaded, then appeared, then shifted when a second weight arrived — three separate problems produced by one @font-face block.

The symptom

$ curl -sI .../fonts/inter-var.woff2 | grep -i content-length
content-length: 412844

$ curl -s .../css/app.css | grep -A3 font-face
@font-face {
  font-family: Inter;
  src: url('/fonts/inter-var.woff2') format('woff2');
}

# no font-display, no unicode-range, no preload. 412 KB
# covering every script in the file. and the default
# display is 'auto', which browsers treat as 'block':
# invisible text for up to three seconds.

Four hundred and twelve kilobytes for a site that renders English and Turkish, with the default display behaviour, is three separate decisions nobody made. The file was correct and had been dropped in by whoever set up the theme in 2019.

Why it happens

A font is added by copying an @font-face block from a foundry’s instructions, which describe how to load the font and not how to load it well. Every descriptor that affects performance is optional and absent by default.

The fix

The four display values, and what each trades

block     invisible up to 3s, then the fallback, then swap
          whenever it arrives → invisible text
swap      the fallback immediately, swap however late
          → a layout shift, possibly seconds later
fallback  invisible ~100ms, then the fallback, and swap
          only within ~3s → a bounded shift window
optional  invisible ~100ms, then the fallback, and NEVER
          swap → no shift ever, and some visitors never
          see the font

no value avoids both invisible text and a layout shift.
that is the whole decision.

optional is the only value that guarantees no layout shift and it is chosen least often, because the idea of a visitor never seeing the brand typeface is uncomfortable. For a body typeface on a page whose metric is Cumulative Layout Shift it is usually the better trade, and it is a conversation with whoever owns the design rather than a technical choice.

A fallback that occupies the same space

@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.4%;
  ascent-override: 90%;   descent-override: 22.4%;
  line-gap-override: 0%;
}

@font-face {
  font-family: Inter;
  src: url('/fonts/inter-subset.woff2') format('woff2');
  font-display: swap;
  unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F;
}

body { font-family: Inter, 'Inter Fallback', sans-serif; }

The overrides adjust the fallback so that a line of Arial occupies the same space as a line of Inter, which makes the swap nearly invisible. The numbers come from comparing the two fonts’ metrics and there are generators that compute them — deriving them by trial and error is possible and takes an afternoon.

They are also specific to one pairing. Changing either the web font or the fallback stack invalidates all four values, and nothing about the stylesheet says so — which is a comment worth writing at the moment they are generated rather than after somebody has spent a morning wondering why the shift returned.

With a metric-compatible fallback, swap becomes affordable and the site keeps its typeface without paying the layout instability. That is the technique that resolves the choice above rather than accepting one side of it, and it is the best answer available in 2022.

Subsetting, which is the largest single saving

$ pyftsubset inter-var.ttf 
    --unicodes='U+0000-00FF,U+0100-017F,U+2000-206F,U+20BA' 
    --layout-features='kern,liga,calt,tnum' 
    --flavor=woff2 --output-file=inter-subset.woff2

$ ls -l inter-*.woff2
412844  inter-var.woff2
 68204  inter-subset.woff2

# 412 KB → 68 KB. the range includes U+0100-017F (Latin
# Extended-A: the Turkish ı, ğ, ş) and U+20BA (the lira).

Latin Extended-A is the range that gets omitted and it contains the dotted and dotless i, which produces a page where one letter falls back to a different typeface mid-word. Testing the subset against real content rather than against a Latin pangram is the check that catches it.

Dropping layout features is where the remaining saving is and is also where a font stops rendering correctly — kern and calt matter for readability and tnum matters for a table of prices. Keeping four and dropping the rest was the balance here.

Preloading exactly one file

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

<!-- crossorigin is REQUIRED even for a same-origin font:
     without it the font is fetched TWICE, because fonts
     are always requested in anonymous mode.
     and only one file — preloading four delays the one
     that is actually needed first. -->

The missing crossorigin is the mistake almost everybody makes and the symptom is a duplicate request that is easy to miss in a waterfall. A variable font covering the weights is what makes preloading one file possible; a family with four static weights has no good answer and preloads the one used above the fold.

Self-hosting, and the privacy question

the reasons to self-host, in 2022:

  performance  a third-party origin is a DNS lookup, a
               connection and a handshake before the first
               byte — and cache partitioning killed the
               shared-cache argument
  privacy      a German court ruled in January that
               embedding a font CDN transmits the visitor's
               IP to a third party without consent
  control      the file cannot change underneath you

it costs a build step, and a subset regenerated whenever
the character set changes.

The cache-partitioning change removed the main technical argument for a font CDN — browsers no longer share a cache entry across origins, so a visitor arriving from another site does not have the font cached. That leaves the privacy question, which in 2022 became a question with a legal answer in at least one jurisdiction.

Measuring in the field

import { onLCP, onCLS } from 'web-vitals'

const send = ({ name, value, attribution }) => {
  navigator.sendBeacon('/vitals', JSON.stringify({
    name, value,
    path: location.pathname,
    conn: navigator.connection?.effectiveType,
    element: attribution?.largestShiftTarget ?? attribution?.element,
  }))
}

onLCP(send); onCLS(send)

The attribution data naming the element is what makes this actionable — knowing that the largest shift is a specific heading is a different conversation from knowing the page shifts. Recording the connection type alongside stops the numbers being dominated by whoever happens to be on a fast connection.

Verifying it worked

$ ./bin/font-render-check --text='Türkçe karakterler: ığşçöü ₺'
  all glyphs present in subset (68,204 bytes)

# field data, 28 days, p75 mobile:
#   LCP  2.9s → 1.6s      CLS  0.18 → 0.01

# and the check that catches a future regression: a CI
# step asserting the subset contains every character used
# in the site's content

The glyph coverage check in CI is the piece that prevents the regression that matters: a content editor adding a language, or a product name with an accented character the subset does not contain. It is a script over the database and the font file, and it is the only automated defence against a font that is subtly wrong.

What this costs

A build step and a font that must be re-subset whenever the character set changes, which is a coupling between the content and the assets that did not previously exist. The CI check makes it visible rather than removing it, and the failure it produces — a build failing because somebody wrote a product name in Greek — is the correct behaviour and will be annoying.

The metric-compatible fallback is also a set of numbers that are correct for one pairing of fonts. Changing either the web font or the fallback stack invalidates them, and nothing about the stylesheet says so — a comment naming the tool that generated them is the cheapest available documentation and is the sort of comment that gets deleted during a tidy-up.