Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

Font Loading Is a Layout Decision: Measured Shift for swap, optional, fallback and block

Most advice about font-display stops at "use swap unless you can't stand the flash". That framing is wrong in two ways. First, swap is not a single behaviour — it is a three-phase timing contract, and what your page does

Most advice about font-display stops at "use swap unless you can't stand the flash". That framing is wrong in two ways. First, swap is not a single behaviour — it is a three-phase timing contract, and what your page does depends on where the font lands inside those phases. Second, the layout shift it causes is not just "text gets slightly wider". On a page that sizes anything in ch, the swap changes the width of the container itself, which changes the line count, which moves everything below it.

I built a small harness to measure this rather than argue about it. Everything below is a number I actually got out of it, with the limits stated at the end.

The setup

One page, one viewport (1366×768), Chromium (system Chrome, driven by Playwright), served over http://127.0.0.1 so the font request can be genuinely intercepted and delayed. Three @font-face rules for Plus Jakarta Sans 400/600/700 — the font the Design Den site actually loads — and a fallback stack identical to the one in that site's index.html:

:root { --font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }

The page is a hero: a display heading on clamp(3rem, 7vw, 7rem) with line-height: .92, a lede paragraph at font-size: 19px; max-width: 52ch, and a pill button underneath. The font response is delayed by 1200 ms to simulate a cold fetch on a real network.

That 52ch is the whole story, and almost nobody notices it.

The mechanism people miss: ch re-resolves when the font swaps

ch is defined as the advance measure of the "0" glyph in the element's current font. So max-width: 52ch is not a fixed width. It is a width that is recomputed the moment the used font changes.

Sampled geometry from the harness, same page, same viewport, nothing else touching it:

Moment Used font 52ch resolves to Lede height Button top
t = 70 ms (fallback render) system-ui → Segoe UI 533 px 88 px (3 lines) 456 px
t = 1270 ms (after swap) Plus Jakarta Sans 723 px 59 px (2 lines) 427 px

The paragraph got 190 px wider — a 35.6 % jump in the column — not because the text grew, but because the "0" glyph in Plus Jakarta Sans is much wider than the "0" in Segoe UI. A wider column fit the same sentence in two lines instead of three. The document height went from 673 px to 644 px and the button moved up 29 px.

So the visible symptom is not "the text wobbles". It is "a paragraph changes its own width and everything under it slides". On a real page with a hero image, a sticky nav offset, or a min-height: 100vh section, that same reflow can push content across a fold.

For reference, the advance-width probe on a 68-character sample string at 700 112px:

  • fallback (system-ui → Segoe UI): 3716.51 px
  • Plus Jakarta Sans: 3885.39 px
  • the webfont is 4.54 % wider on that string

A 4.5 % width difference sounds trivial. Through ch, it became a 35.6 % container difference.

CLS per strategy, repeated three times

The only thing that moves on this page is the button, and it moves only because the lede reflows at the swap — so every layout-shift entry is attributable to the font swap. Each row below is 3 identical repeat runs.

font-display Font arrives at 1200 ms CLS Final 52ch
swap yes 0.00083 723 px
fallback yes 0.00083 723 px
block yes 0.00026 723 px
optional no — font never used for this load 0 533 px
swap + size-adjust/ascent/descent: 96%, line-gap: 0% yes 0.00779 694 px

All values were stable across the repeats — the same configuration gave the identical CLS three times. One earlier single-run sample of fallback reported 0.00581; the repeat run reported 0.00083, and I am quoting the repeat.

What optional is actually doing here

display: optional gives you a ~100 ms block period and a zero-length swap period. If the font is not available when the text is first laid out, the browser uses the fallback for the entire document lifetime and only considers the webfont on a later navigation.

That is why CLS is exactly zero and why the lede stayed at 533 px: the webfont never rendered at all on that load. optional does not fix the shift. It declines the font.

That is a legitimate engineering choice, and it is the one the Design Den homepage makes. Its markup is:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Optional fonts avoid a late typography swap; generated local CSS is inlined for first paint. -->
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700&display=optional"
      rel="stylesheet" media="print" onload="this.media='all'">

media="print" with an onload promotion to all keeps the Google Fonts stylesheet out of the critical path, and display=optional keeps the swap from happening mid-session. The cost is real and should be said out loud: on a first visit over a slow connection, a large part of your audience never sees your brand typeface. They see Segoe UI, or Roboto, or whatever the platform hands back. Your carefully chosen display face is a repeat-visit reward.

If brand typography is load-bearing for you — a studio, a fashion label, a luxury brand — optional may be the wrong trade. If legibility and stability are load-bearing — a form, a booking flow, a legal site — it is a strong one. Decide it deliberately instead of inheriting swap from a boilerplate.

Metric overrides made it worse, not better

The standard advice is to measure the fallback's metrics and apply size-adjust, ascent-override, descent-override and line-gap-override so the webfont occupies the same space. In this harness, applying a plausible-looking flat 96 % to all of them:

@font-face {
  font-family: 'PJS';
  font-weight: 700;
  font-display: swap;
  src: url('/fonts/pjs-700.woff2');
  size-adjust: 96%;
  ascent-override: 96%;
  descent-override: 96%;
  line-gap-override: 0%;
}

…moved the final column width closer to the fallback (694 px instead of 723 px, against 533 px), which is the intended direction — and increased CLS from 0.00083 to 0.00779, roughly nine times worse. The overrides changed the box metrics, not only the advance width, so they altered how many lines the paragraph needed and where things sat, producing additional shift entries.

I am not claiming overrides are bad. I am claiming that a copied percentage is a guess, and that a guess here made the metric you are trying to protect go up. Compute the values against your actual fallback font on your actual target platforms, then measure. If you cannot measure, do not ship them.

The naming trap: text-size-adjust is not size-adjust

While checking this, I found four matches for "size-adjust" in the Design Den CSS — and all four were text-size-adjust: 100% in responsive.css:

body {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

That is a completely different property. text-size-adjust stops mobile browsers from inflating font sizes when they think text is too small to read. size-adjust scales one font face's metrics. Having one in your CSS does not mean you have addressed font swap, and searching your codebase for one finds the other. Worth knowing before you tell yourself this is already handled.

How to measure it yourself

Two things that cost me time:

1. Do not trust document.fonts.check() for "was the font used". In this harness it returned true for display: optional runs where the font demonstrably never rendered. The reliable signal is geometry: measure the rendered width of a ch-sized element, or the document height, and compare against the known fallback value.

2. layout-shift entries are silently not delivered in some automation configurations. My first harness reported CLS 0 for every strategy, which looked like a clean result and was completely false. The cause was navigating with waitUntil: 'commit'. The same page, same observer, same mutation reported shifts correctly under waitUntil: 'load'. A zero you cannot explain is not a measurement — run a deliberate control shift (insert a 200 px block and confirm the observer fires) before you believe any CLS number coming out of a script.

The observer itself is short:

let cls = 0;
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) if (!e.hadRecentInput) cls += e.value;
}).observe({ type: 'layout-shift', buffered: true });

buffered: true matters, or you miss the entries that happened before your script ran.

What I would decide before choosing a font

  1. Pick the font-display behaviour as a brand decision, not a default. optional buys stability and costs first-visit brand type. swap buys the typeface and costs a reflow. Both are defensible; an unexamined one is not.
  2. Stop sizing layout boxes in ch if you are swapping fonts. max-width: 65ch is a nice readable-measure heuristic, but it makes your layout depend on a glyph metric that changes during load. Use rem or a clamp() with a px ceiling for anything whose width affects line count below the fold.
  3. Reserve the space, then measure the reserve. The fix for font shift is the same fix for image shift: give the container a height that does not depend on which font won.
  4. Treat metric overrides as an optimisation with a verification step, not a checklist item.
  5. Preconnect to the font origin. It is one line and it moves the arrival time, which is the variable every strategy keys off.

Limits of this experiment

One machine, one viewport, one Windows fallback (Segoe UI), one font pair, one paragraph, no CPU throttling, no real network RTT — the delay was injected, not measured. These numbers rank strategies on this page; they do not describe your site or anyone's field data, and I would not quote them as a Core Web Vitals prediction. The ch mechanism, the optional behaviour and the override result are the parts I would generalise. The absolute CLS values are the parts you should re-measure.

Disclosure: this article was drafted with AI assistance and reviewed for accuracy by Design Den; the accompanying cover is a conceptual AI-generated 3D illustration with the official Design Den Solutions logo composited unchanged, not a screenshot of any real page. All figures come from the harness described above.

Design Den Solutions builds and maintains responsive websites in Mohali / Chandigarh with performance basics, semantic structure and load-time checks handled as part of the build rather than bolted on afterwards — details on the web design and development service page.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.