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.
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:
- Decide what to show while waiting: invisible text, fallback text, or the fallback for good. This is
font-display. - Make the wait shorter: find the file earlier (preload, self-hosting) and make it smaller (WOFF2, subsetting, variable fonts).
- Make the swap harmless: tune the fallback font so it takes the same space as the web font (
size-adjustand 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.
- 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.
- 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.
- 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.
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.
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".
@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.
- 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. - 300 to 550 ms: CSS. The stylesheet downloads. The browser parses it and records the two
@font-facerules. Nothing is fetched for them yet. - 550 to 600 ms: style and layout. The browser matches rules to elements. It finds text in the
bodythat needsBrand Sans400, and anh1that needsBrand Sans700. Only now does it know which faces are used. - 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. - 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.
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.
| Value | Block period | Swap period | What the reader sees on a slow network |
|---|---|---|---|
auto | Browser decides (about 3 s in the major browsers) | Unlimited | Same as block today |
block | About 3 s | Unlimited | Up to 3 s of invisible text, then fallback, then a late swap |
swap | Very short (100 ms or less) | Unlimited | Fallback text at once, swap whenever the font lands |
fallback | Very short (100 ms or less) | About 3 s | Fallback at once; swap only if the font lands within about 3 s |
optional | Very short (100 ms or less) | None | Font 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.
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?
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.
<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 withoutcrossorigindoesn'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.woff2while the CSS asks forbrand-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.
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?
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.
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.
@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.
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.
@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.
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.
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.
@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-adjustscales 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 computedfont-size, soemunits elsewhere are not affected.ascent-overrideanddescent-overridereplace 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-overridesets the line gap, usually to0%, 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.
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-adjustfrom 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.
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.
unloaded until some text needs it. load() forces it to start; the promise resolves on loaded and rejects on error.// 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-loadedclass tohtmlonce 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Blank text for seconds on slow networks | No font-display, so auto blocks for 3 s | Set swap or optional on every @font-face; add &display=swap to Google Fonts URLs |
| Same font downloaded twice | Preload without crossorigin, or a URL that differs from the CSS | Add crossorigin; preload the exact URL |
| Brand font never appears from a CDN | Font host sends no Access-Control-Allow-Origin | Add the CORS header on the font host, or serve from your origin |
| Page jumps a second after load | Fallback and web font have different metrics | Metric-matched fallback, or optional |
| Fake-looking bold or italic | The face isn't declared, so the browser synthesizes it by smearing or slanting the regular | Declare the face, or set font-synthesis: none to see what's missing |
| A few letters in a different font | Subset missing those glyphs | Widen the subset, or add a unicode-range file for them |
| Wrong text measurements, first visit only | Code measured before the font loaded | Measure 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
remfor 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/fontget self-hosted files,font-display: swapby 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
optionalorfallbackon 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?
Explain that the empty squares are the block period: the icon font's glyphs are invisible until the file arrives, up to 3 s, and swap would be worse because the fallback would show random characters or ligature words.
Propose moving to inline SVG icons, ideally imported per icon so each page ships only the icons it uses. They render with the HTML, need no font request, scale cleanly, and take aria-label or aria-hidden properly.
As a stop-gap, subset the icon font to the icons in use and preload it, which shortens the blank time but doesn't remove it.
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.
- Budget the faces. Two families at most, and only the weights the design uses. Ideally a system stack for UI text.
- Self-host WOFF2, subset to the languages you serve, split with
unicode-rangeif you serve several scripts. Variable font if you use three or more weights. - Cache forever with hashed file names and
immutable. - Preload the one or two faces used above the fold, with
crossorigin. - Choose
font-display:swaporoptionalfor text, neverauto. - Match the fallback with
size-adjustand the overrides, generated bynext/font, Fontaine, or similar. - Measure CLS and LCP from real users, split by first visit and repeat visit.
How deep to go by level
| Level | What a good answer includes |
|---|---|
| Junior | Knows FOIT and FOUT, sets font-display: swap, knows fonts can cause layout shift, limits the number of weights. |
| Mid | Explains the block, swap, and failure periods; preloads the right file with crossorigin; self-hosts WOFF2 subsets; knows the shared cache is gone. |
| Senior | Picks 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-displaysets their lengths;automeans up to 3 s of invisible text. - Use
swapwith a metric-matched fallback, oroptionalwith a preload. Useblockonly 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
crossoriginand 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
immutablecache. - 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, andline-gap-overrideon alocal()fallback; letnext/fontor 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?
FOIT is a flash of invisible text: the browser hides text while the web font downloads. FOUT is a flash of unstyled text: the browser shows a fallback font first and swaps when the web font arrives.
The spec defines a timeline per font: a block period (invisible fallback), a swap period (visible fallback, swap allowed), and a failure period (fallback stays). font-display sets the lengths: block and auto block for about 3 s, swap blocks for 100 ms or less and swaps any time, fallback allows a swap for about 3 s, optional allows no swap.
For text I pick swap with a metric-matched fallback, or optional with a preload when stability matters most. I never leave it on auto.
Question 2
Why do web fonts often load late, and how do you fix it?
The browser requests a font only after it has downloaded and parsed the CSS and matched a rule that uses the face. The preload scanner can't see font URLs inside CSS, so fonts sit at the end of a chain: HTML, CSS, then fonts. Third-party hosts add another CSS hop and new connections.
Fixes: self-host so the font uses the existing connection, and preload the one or two faces used above the fold with as="font", type, and crossorigin, using the exact URL the CSS uses. Make files small with WOFF2 and subsetting, and cache them for a year under hashed names.
Question 3
How do you stop a font swap from causing layout shift?
The shift happens because the fallback and the web font have different widths, ascent, descent, and line gap, so lines wrap differently.
Declare an adjusted fallback face with src: local("Arial"), size-adjust to match average width, and ascent-override, descent-override, and line-gap-override to match vertical metrics. List it right after the web font in font-family. Tools like next/font and Fontaine compute the values from the font files at build time.
Or avoid the swap: font-display: optional uses the font only if it is ready almost at once. Either way, check that the fallback exists on Android, where Arial usually isn't installed.
Question 4
Should we use Google Fonts or self-host?
Self-host for production. Browsers partition the HTTP cache by top-level site, so the old shared-cache benefit is gone, and Google Fonts adds two extra origins and a CSS request before the font file.
Self-hosting gives stable URLs to preload and cache with immutable, full control of subsets and metric overrides, a simpler CSP, and no visitor data sent to a third party. Check the font's license, and let tooling such as next/font download and subset the files at build time.
Question 5
When would you use a variable font, and when a system font stack?
A variable font is one file covering a range of weights (and sometimes widths or optical sizes). It pays off when the design uses three or more weights; for a single weight a static file is smaller.
A system font stack, system-ui and named fallbacks, costs nothing to load and never swaps. It suits app-like UIs, dashboards, and admin tools where speed and native feel matter more than brand typography. A common split is system fonts for UI and body, one web font for headlines.
Practice it
These design problems lean on web fonts.
References
- MDN: font-display The values and the block, swap, and failure periods.
- CSS Fonts Module Level 4: font-display and the font display timeline The spec text for the timeline and recommended period lengths.
- web.dev: Best practices for fonts Loading, delivery, and rendering advice with Core Web Vitals in mind.
- MDN: size-adjust Scaling a fallback face; links to the override descriptors.
- MDN: CSS Font Loading API
FontFace,FontFaceSet, anddocument.fonts. - MDN: Variable fonts guide Axes,
@font-faceranges, andfont-variation-settings. - Next.js: Font optimization How
next/fontself-hosts fonts and generates fallbacks.
Read next
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- The critical rendering pathEverything the browser must fetch and compute before the first pixels: HTML, render-blocking CSS, parser-blocking scripts, then style, layout, paint, and composite, and how to count, measure, and shorten it.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Compression and minificationTwo ways to send fewer bytes for the same code: minification rewrites JS and CSS into a shorter program at build time, and compression (gzip, Brotli, Zstandard) packs bytes for the trip, together often cutting text to a fifth of its size.