cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

Web fonts

Loading custom fonts without invisible text or a jumping page: choosing font-display, preloading the right files, subsetting them, and matching the fallback font's size.

Why fonts hurt the first view

Picture a news site with a custom serif for headlines and a custom sans for body text. On a fast laptop the page looks perfect. On a phone on a train, the reader sees the photo and the buttons, but an empty gap where the headline should be. Two seconds later the headline appears, and the article jumps down by a line just as the reader taps a link.

Nothing was broken. The page was waiting for two font files, and then it moved when they came in. Those are the two classic font problems, and both have names.

FOIT and FOUT

  • FOIT, the flash of invisible text: the browser lays the text out but paints it invisible while it waits for the font. The reader sees blank space where words should be.
  • FOUT, the flash of unstyled text: the browser paints the text at once in a fallback font (say Arial), then swaps to the web font when it arrives. The reader can read right away, but sees the text change shape, and often sees the page move.

Neither flash is free. FOIT costs reading time: on a slow network the text can stay invisible for up to 3 seconds. FOUT costs stability: the web font usually has different letter widths and line heights from the fallback, so lines wrap differently and everything below them shifts. That shift counts towards CLS, Cumulative Layout Shift, one of the Core Web Vitals.

Two phone screens of the same news page, one second after the font was requested on a slow network. On the left, FOIT: the photo shows, but the headline and paragraph are blank boxes, because the text is invisible while the browser waits for the font. On the right, FOUT: the headline and paragraph are readable in the Arial fallback, and will change shape when the web font arrives.FOIT: invisible textFOUT: fallback textblank where the words goreadable now, in Arialswaps to the web font later
Same page, same moment, same slow font. FOIT leaves nothing to read; FOUT shows the words now and changes them later, which is where layout shifts come from.

Why fonts behave differently from other files

Images can arrive late without hiding the text around them, and scripts can be deferred. Fonts sit between the text and the pixels: the browser can't draw a single glyph (the shape of one character) of a web font until it has the file.

Fonts are also found late. The browser learns that this h1 needs a given family at weight 700 only after it has the HTML and the CSS and has matched rules to elements. By then the page could otherwise be painted. So the work falls into three parts:

  1. Decide what to show while waiting: invisible text, fallback text, or the fallback for good. This is font-display.
  2. Make the wait shorter: find the file earlier (preload, self-hosting) and make it smaller (WOFF2, subsetting, variable fonts).
  3. Make the swap harmless: tune the fallback font so it takes the same space as the web font (size-adjust and the metric overrides), or skip web fonts and use the system font.

This lesson builds on two others. The critical rendering path lesson explains why the browser waits for CSS before the first paint; fonts add a second wait on top. The loading strategies lesson covers priorities and preload syntax; here we look at which font files deserve it.

Check your understanding · Exercise 1

A page shows headlines in Arial for half a second, then they change to the brand font and the paragraph below moves down 6 pixels. What is happening? Select all that apply.

The mental model: a three-period timeline

Every web font goes through the same timeline, defined in the CSS Fonts spec. From the moment the browser requests the file, the font is in one of three periods, and the period decides what the reader sees.

  1. Block period. The font hasn't arrived, so the browser draws the text with an invisible fallback: it takes up space but you can't see it. If the font arrives now, it is used at once.
  2. Swap period. The browser now draws the text in a visible fallback font. If the font arrives during this period, the browser swaps it in and re-lays out the text.
  3. Failure period. The font is treated as failed. The fallback stays for the life of the page, even if the file arrives later. The file still lands in the HTTP cache, so the next page can use it.

That is the whole model. FOIT is a long block period. FOUT is the font arriving during the swap period. A font that never shows on a slow connection arrived in the failure period.

A timeline from 0 to 5 seconds after the font request, one row per font-display value. auto and block: a block period of invisible text for 3 seconds, then a swap period with no end. swap: a block period of about 100 milliseconds, then a swap period with no end. fallback: about 100 milliseconds of block, a swap period until about 3 seconds, then the failure period. optional: about 100 milliseconds of block, no swap period, then the failure period for the rest of the page.auto / blockblockswap, no endswapswap, no endfallbackswapfailureoptionalfailure: fallback stays0 s1 s2 s3 s4 s5 stime since the font request (0 s)
Each value is just a choice of two lengths: how long to hide the text, and how long a late font may still swap in. In the failure period the fallback stays for this page.

An everyday comparison

Think of a restaurant waiting for the chef's special sauce. In the block period the waiter holds the plate in the kitchen: you see nothing, but the dish will be perfect. In the swap period the plate comes out with ketchup, and the sauce is swapped in if it shows up while you eat. In the failure period you finish with ketchup, and the sauce goes into the fridge for your next visit.

A wedding cake can wait in the kitchen; a burger should come out now. font-display makes that choice per font face.

When the clock starts

The timer starts when the font is first needed and requested, not when the page starts loading. If the CSS takes 800 ms and the font request starts at 900 ms, a 3 second block period hides the text until about 3.9 seconds after navigation. That is why finding the font earlier (chapter 5) helps every value: the clock starts sooner, and the file arrives sooner within it.

Try it below. Pick a font-display value, set how long the font takes to download, and step through time to see what the reader sees.

font-display:
font-display auto. Periods: block from 0 to 3 s, swap from 3 to the end. The font arrives at 4.0 seconds. At 1.0 seconds the reader sees: invisible.blockswapfont arrivesnow0 s1 s2 s3 s4 s5 s6 swhat the reader sees at 1.0 sblank space
Fallback text from 3 s, then a swap to the web font at 4.0 s. That swap is when the page can shift.

At 1.0 seconds the reader sees invisible.

Check your understanding · Exercise 2

A font has a 100 ms block period and a 3 s swap period. The CSS asks for it at 400 ms after navigation, and the file arrives at 5,000 ms. What does the reader see? Select all that apply.

How the browser loads a font, step by step

A web font starts as an @font-face rule in your CSS. The rule doesn't download anything by itself. It only tells the browser: "there is a font family called this name, and here is where to get the file for this weight and style".

css
@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand-sans-400.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand-sans-700.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

body { font-family: "Brand Sans", Arial, sans-serif; }

Each @font-face rule describes one face: one family at one weight and style. A family with regular, bold, italic, and bold italic is four rules and usually four files. The font-family on body lists the web font first and fallbacks after it, ending in a generic family such as sans-serif.

The steps, with a worked example

Take a page on a 4G phone with a 100 ms round trip, with the HTML, stylesheet, and fonts all on one origin. Here is what happens, with rough, illustrative times.

  1. 0 to 300 ms: HTML. The browser connects (DNS, TCP, TLS), sends the request, and starts receiving HTML. The preload scanner sees the link rel="stylesheet" and requests the CSS.
  2. 300 to 550 ms: CSS. The stylesheet downloads. The browser parses it and records the two @font-face rules. Nothing is fetched for them yet.
  3. 550 to 600 ms: style and layout. The browser matches rules to elements. It finds text in the body that needs Brand Sans 400, and an h1 that needs Brand Sans 700. Only now does it know which faces are used.
  4. 600 ms: font requests. Both font files are requested. The font timeline clock starts for each. With swap, the text is painted at once in Arial.
  5. 600 to 850 ms: font download. Two files of about 25 KB each come in. Each face swaps in as it arrives, and the text re-lays out.

The key point is step 4. The font request can't start until the CSS is downloaded and parsed and styles are computed. Fonts are the last link in a chain: HTML, then CSS, then the fonts the CSS names, and on a slow network each link costs round trips.

A waterfall over one second on a 4G phone. index.html loads from 0 to 300 milliseconds. app.css loads from 300 to 550. Style and layout run from 550 to 600, when the browser learns which faces are used. Only then, at 600 milliseconds, are brand-sans-400 and brand-sans-700 requested; they arrive at 850. For comparison, the same 400 weight preloaded from the HTML loads from 300 to about 560, alongside the CSS.index.htmlapp.cssstyle + layoutbrand-sans-400brand-sans-700400, preloadedclock starts0 ms250 ms500 ms750 ms1000 mstime since navigation
Fonts sit at the end of a chain: HTML, then CSS, then style matching, then the font. A preload starts the font with the CSS and cuts about 300 ms off this example.

Fonts are lazy on purpose

Why wait for style matching? A site often declares faces a given page doesn't use: a design system might list 12 faces, and a login page might use two. Downloading only the faces that some text needs saves bytes on every page.

The same laziness applies to characters. If an @font-face rule has a unicode-range and no text on the page uses a character in that range, the file is never fetched. Chapter 6 uses this to split one big font into small pieces.

The cost of this laziness is late discovery. The preload scanner, which reads ahead in the HTML to find files early, can't see font URLs inside CSS. The fix, preloading, is in chapter 5.

Fonts are always fetched with CORS

Font files are always requested in CORS mode (Cross-Origin Resource Sharing), even from your own origin. A font on another origin, such as a CDN domain, must come with an Access-Control-Allow-Origin header, or the browser refuses to use it. This is also why font preloads need the crossorigin attribute.

Check your understanding · Exercise 3

Which of these must happen before the browser requests a web font that has no preload? Select all that apply.

font-display: the five values in detail

font-display is a descriptor inside @font-face. It sets the length of the block and swap periods for that face. The spec gives recommended lengths for its five values, and the major browsers follow them closely.

The periods run one after another from the font request, and the lengths are approximate: fallback's swap window closes roughly 3 s after the request.
ValueBlock periodSwap periodWhat the reader sees on a slow network
autoBrowser decides (about 3 s in the major browsers)UnlimitedSame as block today
blockAbout 3 sUnlimitedUp to 3 s of invisible text, then fallback, then a late swap
swapVery short (100 ms or less)UnlimitedFallback text at once, swap whenever the font lands
fallbackVery short (100 ms or less)About 3 sFallback at once; swap only if the font lands within about 3 s
optionalVery short (100 ms or less)NoneFont only if it is ready almost at once; otherwise fallback for the whole page

auto and block

auto is what you get if you write nothing, and the major browsers treat it like block: hide the text for up to 3 seconds, show the fallback, then swap whenever the font comes. This is the source of most FOIT on the web, because many sites never set font-display.

Use block on purpose only when the fallback would be wrong rather than just ugly, as with an icon font whose fallback shows meaningless characters. SVG icons are the better fix there.

swap

swap shows text right away in the fallback and swaps to the web font whenever it arrives, even 10 seconds later. It is the most common choice and what Google Fonts gives you with &display=swap in the URL. Text is never hidden for long, which helps FCP (First Contentful Paint) and keeps text-based LCP (Largest Contentful Paint) from waiting on the font.

The cost is the swap itself. If the fallback and the web font take different space, the page moves when the font lands, and a swap at 10 seconds jumps text the reader is in the middle of. swap plus a matched fallback (chapter 7) is a strong default; swap alone moves the problem from FOIT to CLS.

fallback

fallback is swap with a deadline. Fallback text appears at once, and the font swaps in only if it arrives within about 3 seconds. After that the page keeps the fallback and the font is cached for the next page, so there is no late jump while someone is reading.

optional

optional says: use the web font only if it is ready almost at once, otherwise don't use it on this page at all. There is no swap, so there is no swap-related layout shift. On a cold first visit the reader often sees the fallback, but the file usually still downloads, so on the next page the cached font shows from the first paint.

Chrome adds one helpful rule: when an optional font is preloaded, the first render may wait up to about 100 ms for it. On a decent connection a small, preloaded font makes it, so even first-time visitors see it without a swap. On a very slow connection the spec lets the browser abort or deprioritize the download, since the font won't be used anyway.

Mistake to avoid: using optional for a font that is part of the product's identity, like a wordmark set in text. New visitors on slow phones may never see it; optional is for fonts that are nice to have.

A decision flowchart for font-display. First question: is the fallback meaningless, like an icon font? If yes, use block, or better, SVG icons. If no: must the brand font show on every first visit? If yes, use swap with a metric-matched fallback. If no: is the font file small and preloaded, about 30 KB or less? If yes, use optional with a preload. If no, use fallback with a matched fallback.Fallback meaningless?like an icon fontyesblockbetter: SVG iconsnoBrand font a must?on every first visityesswap+ matched fallbacknoSmall, preloaded file?about 30 KB or lessyesoptional+ preloadnofallback+ matched fallback
Never leave a text font on auto. For body text the answer is almost always swap or optional; both want help, from a matched fallback or a preload.

Decision Which font-display value should a content site use for its body text?

swap

Fallback text at once

Swap whenever the font arrives

Pros

  • Text readable right away
  • The brand font always shows in the end
  • Default on Google Fonts with display=swap

Cons

  • Late swaps can move text the reader is reading
  • Needs a tuned fallback to avoid CLS

fallback

Fallback text at once

Swap only within about 3 s

Pros

  • Text readable right away
  • No very late jumps

Cons

  • Early swaps still shift layout without a tuned fallback
  • Slow visitors may not see the brand font on the first page

optional

Use the font only if it's ready in about 100 ms

Otherwise fallback for the page

Pros

  • No swap, so no font-caused layout shift
  • Fast first paint on every network

Cons

  • First-time visitors on slow networks don't see the brand font
  • Works best with a preload and a small file

What we pick: For body text on a content site, optional with a preload, or swap with a metric-matched fallback. Both keep text visible and the page still. Pick swap when the brand font matters to the design team on every visit; pick optional when stability and speed matter most. Avoid leaving it on auto.

Check your understanding · Exercise 4

Which font-display values guarantee the reader never sees invisible text for more than about 100 ms? Select all that apply.

Getting the file early: preload and self-hosting

Every font-display value works better when the font arrives sooner. With swap, an early font means an early swap, before the reader starts reading. With optional, it decides whether the font is used at all.

Preload the right file, and only that file

A preload names a file in the HTML itself, so the request starts while the CSS is still downloading. The loading strategies lesson covers the syntax and priorities. For fonts there are three rules on top.

html
<link rel="preload" href="/fonts/brand-sans-400.latin.woff2"
      as="font" type="font/woff2" crossorigin>
  • Always add crossorigin, even for a font on your own origin. Fonts are fetched in CORS mode, so a preload without crossorigin doesn't match the real request: the font downloads twice and Chrome warns that the preload was unused.
  • Preload the exact URL the CSS uses: same path, format, and subset. Preload brand-sans-400.woff2 while the CSS asks for brand-sans-400.latin.woff2, and you pay for both.
  • Preload one or two files, not the family. Preloads are fetched at high priority and compete with the CSS and the LCP image. Preload the faces used above the fold, usually the body regular and maybe the headline weight.

A preload is a promise to use the file. If no text uses that face, Chrome logs a warning a few seconds after load and the bandwidth was wasted. Mistake to avoid: a shared layout that preloads four faces on every page, including pages that use one.

The Google Fonts chain

Google Fonts is easy: one link tag to a stylesheet on fonts.googleapis.com. That stylesheet, built for the user's browser, holds @font-face rules pointing at files on fonts.gstatic.com. That is two extra origins, each needing its own DNS lookup, TCP connection, and TLS handshake, before the first byte of font arrives.

A sequence diagram with four columns: the browser, your origin, fonts.googleapis.com, and fonts.gstatic.com. The browser gets the HTML from your origin. The HTML links a stylesheet on googleapis, so the browser opens a new connection and gets the font CSS. That CSS names a file on gstatic, so the browser opens another connection and only then downloads the WOFF2 file. A note says that a self-hosted font comes from your origin on the connection that is already open.browseryour origingoogleapisgstaticGET /HTML + CSS linkconnect, GET css@font-face rulesconnect, GET .woff2font fileself-hosted: one request, on the connection already open
Three hops before the first font byte, two of them to new origins. preconnect warms the connections but can't remove the CSS hop; self-hosting removes both.

The usual patch is preconnect: link rel="preconnect" href="https://fonts.gstatic.com" crossorigin sets up the connection to the font host early, in parallel with the CSS. That saves the connection setup, a few round trips, but the font URL is still only known after the Google stylesheet arrives.

The shared cache is gone

The old argument for Google Fonts was the shared cache: millions of sites use Roboto, so the user probably has it cached already. To stop sites from learning your history by timing cache hits, browsers now partition the HTTP cache by top-level site: Safari since 2013, Chrome since version 86 (2020), and Firefox since version 85 (2021).

So Roboto cached on site A is not reused on site B, and every site pays for its own copy. A third-party font host now brings the extra connections and no cache benefit.

Decision Where should the font files live?

Google Fonts (or another font CDN)

One link tag to the provider's CSS

Provider serves WOFF2 split into unicode-range subsets

Pros

  • Zero setup
  • Good subsetting and compression done for you
  • Easy to try fonts

Cons

  • Two extra origins and a CSS hop before the font
  • No shared cache benefit any more
  • Hard to preload, since file URLs can change
  • Sends visitor IP addresses to a third party, which a Munich court found unlawful without consent in 2022

Self-host on your origin or your CDN

Download the WOFF2 files and serve them with your assets

Write your own @font-face rules, inline in the HTML or in your main CSS

Pros

  • No extra connections: the font rides the connection the page already has
  • Stable URLs you can preload and cache for a year with immutable
  • Full control of subsets, font-display, and metric overrides

Cons

  • You must check the font's license allows self-hosting
  • You do the subsetting and updates yourself, or let a tool do it

What we pick: Self-host for anything that matters. Frameworks make this the default: next/font downloads Google Fonts at build time and serves them from your own domain. Use the Google Fonts link for prototypes.

When you self-host, give font files a hashed or versioned name and send Cache-Control: public, max-age=31536000, immutable. A published font file never changes; a new version gets a new name. Repeat visits then get the font from the disk cache with no request, which is what makes optional show the web font from the second page on.

Check your understanding · Exercise 5

A site self-hosts four faces of its font: regular, italic, bold, and bold italic. The home page hero uses bold; the body uses regular; italics appear only in some articles. What should it preload on the home page? Pick one.

Making the file smaller: WOFF2, subsets, variable fonts

The second way to shorten the wait is to send fewer bytes. A font file holds a drawing for every glyph it supports, plus tables for spacing, kerning (the space between letter pairs), and ligatures (joined letters like fi). A family covering Latin, Greek, and Cyrillic in many weights can weigh several hundred kilobytes, and a typical English page needs a fraction of that.

WOFF2 is the only format you need

Designers ship fonts as TTF or OTF; the web formats wrap them with compression. WOFF2 uses Brotli plus font-specific transforms, and is commonly about 30% smaller than the older WOFF and about half the size of the raw TTF. Every browser in use today supports it, so a single src: url(...) format("woff2") is enough, and old EOT, SVG, TTF, and WOFF fallbacks can go.

Since WOFF2 is already compressed, don't gzip or Brotli it again at the server; it gains nothing and costs CPU.

Subsetting: keep only the glyphs you use

Subsetting means building a new font file with only some of the glyphs. A font with 2,000 glyphs for many languages might be 150 KB as WOFF2, while a Latin subset of about 220 glyphs, enough for English and most Western European languages, is often 20 to 35 KB. Tools such as pyftsubset (from the fontTools project) and glyphhanger do this.

text
pyftsubset BrandSans.ttf \
  --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+2000-206F,U+20AC,U+2122" \
  --layout-features="kern,liga" \
  --flavor=woff2 \
  --output-file=brand-sans.latin.woff2

The risk is cutting too much. If the subset lacks é and a user is named Zoé, the browser draws that one letter from the fallback font, in the middle of the name. Check product names, user content, currency signs, and smart quotes, and check that the license allows subsetting.

unicode-range: subsets the browser picks for you

unicode-range lets you ship several subsets under one family name, and the browser fetches only what the page's text needs. You write one @font-face rule per subset, with the same family, weight, and style, and a different unicode-range.

css
@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand-sans.latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F;
  font-display: swap;
}

@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand-sans.cyrillic.woff2") format("woff2");
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1;
  font-display: swap;
}

An English page downloads only the Latin file; a page with one Russian word downloads both. Google Fonts serves every family this way. Japanese and Chinese fonts, with thousands of glyphs and several megabytes in full, are cut into over a hundred small slices, and a page fetches only the slices its characters fall in.

One family, Brand Sans, split into five files by unicode-range: latin, latin-ext, cyrillic, greek, and vietnamese. An English page with the text Zoë's café fetches only the latin file, about 25 KB. A mixed page with a Russian greeting fetches the latin and cyrillic files, about 50 KB. The other slices are never downloaded.English page: Zoë's cafélatinlatin-extcyrillicgreekvietnam.fetches 1 file, about 25 KBMixed page: Привет, Zoëlatinlatin-extcyrillicgreekvietnam.fetches 2 files, about 50 KBone family, five @font-face rules, five slices
The browser checks each character on the page against each rule's unicode-range and fetches only the slices it needs. Google Fonts serves CJK families as over a hundred such slices.

Variable fonts: one file, many weights

A static font file holds one weight and one style. A variable font holds a design that can vary along axes: weight (wght), width (wdth), slant (slnt), italic (ital), and optical size (opsz) are the standard ones. A file whose weight axis runs from 100 to 900 can draw any weight in that range, including values like 450.

css
@font-face {
  font-family: "Brand Sans";
  src: url("/fonts/brand-sans-var.latin.woff2") format("woff2");
  font-weight: 100 900; /* a range, not one value */
  font-display: swap;
}

h1 { font-weight: 750; }
.caption { font-variation-settings: "opsz" 12; }

A variable file is bigger than any single static weight, because it stores how each glyph changes. The win comes with several weights: for one weight ship a static file, but from about three weights the variable file is usually smaller than the static files combined, and it is one request instead of several.

Prefer the standard properties (font-weight, font-stretch, font-style) over font-variation-settings, which sets all axes at once and doesn't cascade per axis. Use the calculator to compare setups and watch the bytes a first visit pays.

weights:
characters:
format:files:
Your setup: 4 files, 880 KB in total, about 4400 milliseconds on a 1.6 megabit connection. The starting point, 4 static TTF files with every glyph: 880 KB, about 4400 milliseconds.your setup4 files, 880 KB, about 4.4 s at 1.6 Mbpsstart: 4 static TTFs, every glyph4 files, 880 KB, about 4.4 s at 1.6 Mbpseach block is one font file, widths to scale
A variable file would be about 616 KB, one request instead of 4. Sizes are rough figures for a typical text font; measure your own files.

Check your understanding · Exercise 6

A site uses its brand font at weights 300, 400, 600, and 800, for English text only. Which changes are likely to cut font bytes? Select all that apply.

Stopping the jump: matching the fallback's size

With swap or fallback, the reader sees the fallback first and the web font later. If the two take the same space, only the letter shapes change and nothing moves. If they don't, lines wrap at different words and every block below shifts, so the fix is to make the fallback take the same space as the web font.

What makes two fonts take different space

Every font is drawn on a grid called the em square. The font's metrics, numbers stored in the file, say how its glyphs sit in that square. Four of them decide the space text takes:

  • Advance widths: how wide each glyph is. A wider font makes each line longer, so lines wrap sooner and paragraphs gain lines.
  • Ascent: how far above the baseline the font reaches, for tall letters and accents.
  • Descent: how far below the baseline it reaches, for letters like g and y.
  • Line gap: extra space the font asks for between lines. With line-height: normal, the line box is ascent plus descent plus line gap.

Arial and a typical brand sans can differ by several percent in average width, ascent, and descent. Over a 60-word paragraph, a 7% width difference is enough to add or remove a line, and one extra 24 px line pushes everything below it down 24 px.

Three fonts at a 100 pixel font size, with the same word set in each. Arial: the word is 150 pixels wide, ascent 90, descent 21, a line box of about 115 pixels. Brand Sans, the web font: 162 pixels wide, ascent 97, descent 24, a line box of about 121 pixels. Arial adjusted with size-adjust 108 percent and ascent and descent overrides: 162 wide, ascent 97, descent 24, the same as Brand Sans.ArialHandglovesascent 90descent 21width 150 pxline box 115 pxArial, adjustedHandglovesascent 97descent 24width 162 pxline box 121 pxBrand SansHandglovesascent 97descent 24width 162 pxline box 121 pxbaselineat a 100 px font size
Plain Arial is narrower and shorter, so text wraps and stacks differently. size-adjust fixes the width; ascent-override and descent-override fix the height. The numbers are illustrative.

size-adjust and the override descriptors

CSS lets you adjust a local font through @font-face. You declare a new family, say Brand Sans Fallback, whose src is local("Arial"), and add descriptors that reshape Arial's metrics to match the web font.

css
@font-face {
  font-family: "Brand Sans Fallback";
  src: local("Arial");
  size-adjust: 107.4%;      /* scale glyph widths and heights */
  ascent-override: 90.2%;   /* of the font size */
  descent-override: 22.5%;
  line-gap-override: 0%;
}

body {
  font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}
  • size-adjust scales the whole fallback font, outlines and metrics together, by a percentage. It is the main tool for matching average width, and it doesn't change the computed font-size, so em units elsewhere are not affected.
  • ascent-override and descent-override replace the ascent and descent in the file with a percentage of the font size. They fix the line height and where text sits vertically.
  • line-gap-override sets the line gap, usually to 0%, to match a web font that has none.

The values come from the two fonts' metric tables, not from guessing. size-adjust is roughly the ratio of the web font's average character width to Arial's. Each override is the web font's ascent or descent divided by its em size, then divided by the size-adjust, because the browser scales the overrides by size-adjust too. The numbers above are illustrative; yours will differ.

Let a tool generate them

Few teams compute these by hand. next/font in Next.js reads the font at build time, generates a fallback @font-face with size-adjust and the overrides based on Arial or Times New Roman, and adds it to the font-family. Fontaine, from the UnJS ecosystem, does the same as a build plugin for any framework. Capsize, a library from SEEK, exposes the metrics of many fonts if you want to write the rules yourself.

Toggle the matched fallback below and step the font in. With plain Arial the paragraph rewraps and the button jumps; with the adjusted fallback the words land in the same places.

fallback:
moment:
A product page paragraph in Arial, set in 4 lines of 23 pixels, with a Shop now button below it. Before the swap, with the Arial fallback.set in ArialOur spring sale starts today. Every jacket, boot,and backpack in the outdoor range is thirty percentoff until Sunday night, and orders over fiftydollars ship free to any address in the country.Shop nowbefore the swap
Plain Arial is narrower and has shorter lines, so it fits the text in 4 lines; the web font needs 5. The swap pushes the button down 38 px.

Limits of metric matching

  • It matches averages, not every glyph, so a line can still break at a different word now and then. The shift becomes rare and small, not zero.
  • The fallback must exist on the device. Arial is on Windows, macOS, and iOS, but usually not on Android or Linux, where local("Arial") finds nothing. Tools often list a second local font, such as Roboto on Android, with its own numbers.
  • Support varies. Chrome and Firefox support all four descriptors. Safari supports size-adjust from version 17, but at the time of writing the three overrides are only in Safari Technology Preview, so vertical metrics may still differ there.

Check your understanding · Exercise 7

After adding a fallback @font-face with src: local("Arial") and size-adjust, the page still shifts on Android phones. What is the most likely reason? Pick one.

System font stacks and the Font Loading API

Two tools sit at opposite ends. A system font stack skips web fonts entirely: zero bytes, no waiting, no swap. The Font Loading API gives JavaScript control over when each font loads and what happens then.

System font stacks

Every operating system ships a well-designed UI font: San Francisco on Apple devices, Segoe UI on Windows, Roboto on Android, and often Cantarell or Ubuntu on Linux desktops. A system font stack asks for whichever one the device has.

css
body {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto,
    "Helvetica Neue", Arial, "Noto Sans", sans-serif,
    "Apple Color Emoji", "Segoe UI Emoji";
}

code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas,
    "Liberation Mono", monospace;
}

system-ui is the standard generic family for the platform's UI font; the names after it cover older browsers and gaps, and ui-monospace does the same for code. Bootstrap since version 4 and the WordPress admin use stacks like this. The text is drawn at the first paint and matches the rest of the user's device.

The costs: the page looks a little different on each platform, and the brand has no typographic voice. A common middle ground is a system stack for body text and UI, and one web font, subset and preloaded, for headlines or the logo.

Mistake to avoid: relying on system-ui alone on a multilingual site. Under some Windows language settings it has picked a font that suits that language but looks odd for Latin text, so list "Segoe UI" and the others after it.

The Font Loading API

document.fonts is a FontFaceSet: all the fonts the document knows about, from CSS and from script. It lets you load fonts on demand, check whether one is ready, and wait for loading to finish. Each font is a FontFace object whose status moves through four values.

A state machine for a FontFace object's status. It starts as unloaded. When text needs it or script calls load, it moves to loading. From loading, it moves to loaded when the file arrives and parses, or to error when the network fails or the file is not a valid font. document.fonts.ready resolves once no face is loading.unloadedloadingloadederrorload()or text needs itfile parsednetwork or bad filedocument.fonts.ready: nothing left loading
A face declared in CSS stays unloaded until some text needs it. load() forces it to start; the promise resolves on loaded and rejects on error.
js
// Load a face from script, then add it to the document.
const face = new FontFace("Brand Serif", "url(/fonts/brand-serif-700.woff2)", {
  weight: "700",
  display: "swap",
});
document.fonts.add(face);
await face.load(); // status: loading, then loaded or error

// Ask for whatever CSS face matches, and wait for it.
await document.fonts.load("700 2rem 'Brand Serif'");

// Is it ready without a download? (true once loaded)
document.fonts.check("700 2rem 'Brand Serif'");

// Resolves when pending font loads and layout are done.
await document.fonts.ready;

When to use it in practice:

  • Measuring text. A canvas chart, a virtualized list that measures row heights, or a text-fitting headline must wait for document.fonts.ready. Measure earlier and you measure the fallback.
  • Canvas and WebGL text. Canvas doesn't re-draw when a font arrives. Call document.fonts.load() for the font, then draw.
  • Grouping swaps. With several faces, each swap re-lays out the page. Loading them through the API and adding a fonts-loaded class to html once all are ready turns several reflows into one.
  • Fonts for later. A rich text editor with a font picker can load each font only when the user picks it.

Mistake to avoid: hiding text with a class until document.fonts.ready resolves. That rebuilds FOIT in JavaScript, with no 3-second limit, and if the script fails the text never appears. Use the API to improve a CSS setup that already works, not to replace font-display.

Check your understanding · Exercise 8

A chart draws axis labels on a canvas with the brand font. On first visits the labels appear in a default font and never update. What fixes it? Select all that apply.

Failure modes, accessibility, and real products

Font bugs rarely show up on a developer's machine, where fonts are cached, the network is fast, and the font may be installed locally. They show up on a cold cache and a slow phone, so test with DevTools throttling, the cache disabled, and the web font not installed.

What breaks in production

SymptomLikely causeFix
Blank text for seconds on slow networksNo font-display, so auto blocks for 3 sSet swap or optional on every @font-face; add &display=swap to Google Fonts URLs
Same font downloaded twicePreload without crossorigin, or a URL that differs from the CSSAdd crossorigin; preload the exact URL
Brand font never appears from a CDNFont host sends no Access-Control-Allow-OriginAdd the CORS header on the font host, or serve from your origin
Page jumps a second after loadFallback and web font have different metricsMetric-matched fallback, or optional
Fake-looking bold or italicThe face isn't declared, so the browser synthesizes it by smearing or slanting the regularDeclare the face, or set font-synthesis: none to see what's missing
A few letters in a different fontSubset missing those glyphsWiden the subset, or add a unicode-range file for them
Wrong text measurements, first visit onlyCode measured before the font loadedMeasure after document.fonts.ready

Accessibility

  • Invisible text is inaccessible text. During FOIT the text is in the DOM, so screen readers can read it, but sighted users on slow networks, often on cheap phones, can't. Short block periods are an accessibility choice.
  • Respect user font settings. Some readers choose their own fonts, such as a dyslexia-friendly typeface, or larger sizes. Use rem for sizes and don't fight user stylesheets with !important.
  • Avoid icon fonts. Screen readers may announce the private code points or ligature words behind icons, and the icons break when fonts fail or when readers override page fonts. Inline SVG icons with proper labels avoid these problems.
  • Readable fallbacks. If the web font fails for good, the page lives in the fallback, so pick one that is legible at your sizes.

Privacy and security

Third-party font hosts see every visitor's IP address, user agent, and the page that asked (through the Referer header). In January 2022 the Munich Regional Court ruled that a site embedding Google Fonts from Google's servers had passed a visitor's IP address to Google without consent, which pushed many European sites to self-host. Self-hosting also removes a third party from your Content Security Policy: font-src 'self' is enough.

The local() source has a privacy angle too: the list of fonts a device has installed is a fingerprinting signal. Browsers restrict it in different ways; Safari, for example, only exposes fonts that ship with the system. That is one more reason to use local() only for well-known system fonts, like the Arial fallback in chapter 7.

In real products

  • GitHub draws its interface with a system font stack, so text never waits on a font file.
  • Next.js sites using next/font get self-hosted files, font-display: swap by default, preloads, and a generated metric-matched fallback: this lesson's recipe, done at build time.
  • News and publishing sites are a natural fit for optional or fallback on body text, so a late font never moves an article someone is already reading.

Think first

Your team's design system still uses an icon font with font-display: block. A manager asks why the toolbar shows empty squares for 3 seconds on slow networks. What do you propose?

Check your understanding · Exercise 9

Which checks catch font problems that a fast developer laptop hides? Select all that apply.

Using it in a system design interview

Fonts rarely get their own interview question, but they come up in almost every performance discussion: a news site, a product page, a design system. Bring them up when you talk about the first paint, LCP, or CLS. A sentence or two at the right moment shows depth; a five-minute lecture does not.

The recipe to draw

If the interviewer wants detail, draw the recipe as layers, from the CSS down to the network, then walk through the steps.

The font recipe as four layers. CSS, what to show while waiting: font-display swap or optional, a metric-matched fallback, and a system font stack for UI text. HTML head, find it early: preload the top one or two faces, with crossorigin, using the exact URL the CSS uses. Files, send fewer bytes: WOFF2 only, subsets split with unicode-range, a variable font if three or more weights. Delivery, arrive from close by: self-hosted, hashed file names, cached immutable for a year.CSS: what to show while waitingswap or optionalmatched fallbacksystem UI stackHTML head: find it earlypreload top facescrossoriginexact CSS URLFiles: send fewer bytesWOFF2 onlysubset + rangesvariable if 3+Delivery: arrive from close byself-hostedhashed file namesimmutable, 1 year
Draw this when an interviewer asks how you would load fonts. Each layer answers one problem: what the reader sees, when the file is found, how big it is, and where it comes from.
  1. Budget the faces. Two families at most, and only the weights the design uses. Ideally a system stack for UI text.
  2. Self-host WOFF2, subset to the languages you serve, split with unicode-range if you serve several scripts. Variable font if you use three or more weights.
  3. Cache forever with hashed file names and immutable.
  4. Preload the one or two faces used above the fold, with crossorigin.
  5. Choose font-display: swap or optional for text, never auto.
  6. Match the fallback with size-adjust and the overrides, generated by next/font, Fontaine, or similar.
  7. Measure CLS and LCP from real users, split by first visit and repeat visit.

How deep to go by level

LevelWhat a good answer includes
JuniorKnows FOIT and FOUT, sets font-display: swap, knows fonts can cause layout shift, limits the number of weights.
MidExplains the block, swap, and failure periods; preloads the right file with crossorigin; self-hosts WOFF2 subsets; knows the shared cache is gone.
SeniorPicks swap versus optional from the product's needs, matches fallback metrics with size-adjust and overrides, plans unicode-range for multilingual or CJK sites, and sets a font byte budget measured in the field.

What to say

A good one-breath answer for a content site: "Body text uses a system stack. Headlines use one self-hosted variable WOFF2, Latin subset, about 40 KB, preloaded with crossorigin, font-display: swap, and a size-adjusted Arial fallback generated at build time, so the swap doesn't move anything. Files are hashed and cached for a year." Then stop and let the interviewer pick a thread.

Check your understanding · Exercise 10

An interviewer says: "Our product page has good LCP but CLS of 0.15, mostly in the first second." Which font-related steps are worth proposing? Select all that apply.

Summary

  • Every web font has a block period (invisible text), a swap period (fallback shown, swap allowed), and a failure period (fallback stays). font-display sets their lengths; auto means up to 3 s of invisible text.
  • Use swap with a metric-matched fallback, or optional with a preload. Use block only where a fallback would be meaningless, and prefer SVG over icon fonts.
  • Fonts are discovered late, after the CSS is parsed and styles are matched. Preload only the one or two faces used above the fold, with crossorigin and the exact URL the CSS uses.
  • Self-host: cache partitioning removed the shared-cache benefit of Google Fonts, which still adds extra origins and a CSS hop. Serve hashed files with a one-year immutable cache.
  • Ship WOFF2 only, subset to the characters you need, split scripts with unicode-range, and use a variable font when you need three or more weights.
  • Stop swap-induced CLS with size-adjust, ascent-override, descent-override, and line-gap-override on a local() fallback; let next/font or Fontaine generate them.
  • System font stacks cost zero bytes and never swap. The Font Loading API (document.fonts) is for measuring, canvas text, and grouping swaps, not for hiding text.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What are FOIT and FOUT, and how does font-display control them?

Question 2

Why do web fonts often load late, and how do you fix it?

Question 3

How do you stop a font swap from causing layout shift?

Question 4

Should we use Google Fonts or self-host?

Question 5

When would you use a variable font, and when a system font stack?

Practice it

These design problems lean on web fonts.

References

Read next