CSE 134B Web Client Languages
  1. Presentation
  2. Fonts
  3. Web fonts and performance

Presentation · Fonts 07

Web fonts and performance

A web font is a download that stands between the reader and the words. Used well, it costs a few dozen kilobytes and goes unnoticed; used carelessly, it hides text for seconds, shakes the layout when it lands, and reports the reader's visit to a third party. This page is about getting the first outcome.

Earlier pages chose typefaces for what they say. This one follows a font from the @font-face rule that names it, through the file formats and the servers that deliver it, to the moments the browser has to decide what to draw while it waits. Two labs let you watch those decisions, and the site's own type, IBM Plex, serves as the worked example throughout.

1. What a web font costs

Every page has type, but not every page needs to download it. Text set in system-ui, or in Georgia or Arial where they are installed, uses fonts already on the reader's device: no request, no wait, no risk. A web font is one the page brings along, and it buys control over the look of every word at the price of a file that must arrive before those words can be drawn in it.

That price is unusual because of where it falls. An image that arrives late leaves a gap in the layout; the text around it is readable. A font that arrives late affects the text itself, which is usually the reason the reader came. The browser has three choices while it waits, and each costs something:

  • Wait, drawing nothing. The reader sees a page of empty lines, and on a slow connection may wait seconds to read a headline that was in the HTML all along.
  • Draw a fallback, then swap. The words are readable at once, but they change shape when the font lands, and lines rewrap and push the page down.
  • Draw a fallback and keep it. Nothing moves, but this visit never looks as designed.

Most of this page is about choosing among those and making the choice cheaper. The course's rule for images applies unchanged: less data, less often, when needed, from nearby. A font that is not needed is the fastest of all.

2. The @font-face rule

A web font enters a page through an @font-face rule. The rule does not set any text in the font; it describes a font face, gives it a family name, and says where its file is. Text uses it only when some font-family names that family, and the browser downloads the file only when text on the page actually needs it.

@font-face { font-family: "Inter"; src: url("/fonts/inter-var.woff2") format("woff2"); font-weight: 100 900; font-style: normal; font-display: swap; unicode-range: U+0000-00FF, U+2013-2014, U+2018-201E, U+2022, U+2026, U+20AC; } body { font-family: "Inter", system-ui, sans-serif; }

The family name is yours to choose: "Inter" here, but "Body text" would work as well, since it only connects this rule to the font-family lists that ask for it. The descriptors fall into three groups.

The @font-face descriptors, by job
JobDescriptorsWhat they do
Naming and findingfont-family, srcThe name text will ask for, and a list of places to get the font: url() with an optional format() and tech() hint, or local() for a font already installed
Matchingfont-weight, font-style, font-stretch (renamed font-width in CSS Fonts 4; use font-stretch for now), unicode-rangeWhich requests this file answers: a weight of 700 looks for the face whose weight range includes 700; a character looks for the face whose range includes it
Behaviorfont-display, size-adjust, ascent-override, descent-override, line-gap-override, font-feature-settings, font-variation-settingsWhat to draw while the file loads, and how to adjust the font once it has

A family is usually several rules with one name. Two static files for regular and bold are two @font-face rules, both font-family: "Plex", one with font-weight: 400 and one with 700; the browser picks between them by the weight the text asks for. A variable font covers a range with one file, so its rule declares the range, font-weight: 100 900. Variable fonts, page 08 of this section, explains the axes. If no face matches a request for bold or italic, the browser fakes one by smearing or slanting the regular, which is why a missing face shows up as ugly type rather than an error.

A rule that left and came back

@font-face is older than most of CSS. It was in CSS2, a W3C Recommendation in May 1998, at a time when the only browser that implemented @font-face was Internet Explorer, using a format of its own. Other browsers did not follow, and when CSS 2.1 tidied the specification to match what browsers did, its list of changes says that "font descriptors, the '@font-face' declaration, and all associated parts of the font matching algorithm have been removed in CSS 2.1." For a decade, web type meant the handful of fonts on everyone's computer, plus text set in images.

The rule came back with CSS Fonts Level 3 and with browsers that loaded ordinary font files: Safari 3.1 in 2008, then Firefox 3.5 and Chrome in 2009 and 2010. A short history of type tells the longer story, including the image and Flash workarounds of the years between.

3. Font files and formats

A font file is not a picture of letters. It is a small database of tables, each named by a four-letter tag, and the browser consults several of them to draw a single word:

Inside a font file: the tables a browser reads to draw textA pipeline from left to right. Characters, such as f followed by i, go to the cmap table, which maps each character to a glyph number. GSUB substitutes glyphs, for example replacing f and i with an fi ligature. GPOS positions them, adding kerning and placing accents. The glyf or CFF table supplies each glyph's outline, and hmtx its advance width. A rasterizer turns outlines into pixels, guided by hinting instructions. Below, other tables: hhea and OS/2 hold the vertical metrics used for line height; name holds the font's names and license; fvar and gvar hold a variable font's axes and variations."f" "i"characterscmapcharacter → glyphGSUBf + i → fiGPOSkerning, marksglyf / CFF, hmtxoutlines, widthsrasterizer → pixelshinting: fpgm, prep, cvthhea, OS/2ascent, descent, gapnamenames, licensefvar, gvarvariable axesRead on the side:Read for every run of text:
A font is tables. cmap finds glyphs, GSUB and GPOS shape them, glyf or CFF draws them, and hhea and OS/2 decide how tall a line is.

A character is not a glyph (Anatomy and vocabulary), and cmap is where one becomes the other. The outlines in glyf (TrueType's quadratic curves) or CFF (PostScript's cubic ones) are the letters themselves. GSUB and GPOS carry the font's intelligence: ligatures, kerning, and the placement of accents, without which Arabic or Devanagari cannot be set at all. Hinting tables hold small programs that fit outlines to the pixel grid at small sizes. The vertical metrics return in section 10. None of this needs mastering, but it explains why a font file is larger than its letters suggest, and why cutting it down takes a tool.

Five formats in twenty years

The same tables have traveled to browsers in several wrappers, and the history explains a lot of copied CSS that is still around:

Web font formats
FormatWhat it wasWho used itToday
EOTEmbedded OpenType: Microsoft's compressed font, which could carry a list of the sites allowed to use itInternet Explorer, from version 4Dead with Internet Explorer
SVG fontsGlyphs drawn as SVG paths, without hinting or most OpenType featuresSafari on the iPhone, which before iOS 4.2 accepted no other web font; Chrome until version 38 removed themRemoved from Chrome; never in Firefox; do not serve
TTF / OTFThe desktop font file itself, TrueType or OpenType, uncompressedSafari 3.1 onward, then every browserWorks, but with no font-specific compression
WOFFWeb Open Font Format: the same tables, each compressed with zlib, plus optional metadata. W3C Recommendation, 13 December 2012Every browser from about 2011, including Internet Explorer 9Supported everywhere, superseded
WOFF2The same idea with Brotli compression and table transforms that make outlines compress better. W3C Recommendation, 1 March 2018Chrome 36, Firefox 39, Safari 10, Edge 14 onwardThe one format to serve

The gains are real. This section's Inter, rewrapped with fonttools for comparison: 168,596 bytes as a raw TrueType file, 88,600 as WOFF, 72,920 as WOFF2, the file the demos load. When every browser in use reads WOFF2, a long list of fallbacks only adds noise, so the classic "bulletproof" syntax from the early 2010s can go:

@font-face { font-family: "Plex"; src: url("plex.eot"); src: url("plex.eot?#iefix") format("embedded-opentype"), url("plex.woff2") format("woff2"), url("plex.woff") format("woff"), url("plex.ttf") format("truetype"), url("plex.svg#plex") format("svg"); } @font-face { font-family: "Plex"; src: url("plex.woff2") format("woff2"); font-display: swap; }

The ?#iefix trick worked around a parsing bug in Internet Explorer 6 to 8, and the SVG line served iPhones of 2010. A browser too old for WOFF2 still gets the page's text in a fallback font, which is exactly the outcome a font stack ending in a generic family guarantees.

4. Self-hosting or a font service

There are two ways to get a font onto a page. Self-hosting means the WOFF2 files sit on your own server and your CSS has the @font-face rules. A font service, such as Google Fonts or Adobe Fonts, hosts the files and the CSS, and the page links to it:

<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap">

Three lines and it works: the service picks formats, splits fonts with unicode-range, and keeps up with new versions. The costs are less visible:

  • Two more origins. The CSS comes from one host and the fonts from another, and each needs its own DNS lookup, TCP connection and TLS handshake before the first byte. The preconnect hints start that work early (link relationships), but they cannot make it free.
  • A render-blocking style sheet you do not control. The service's CSS is small, but the page cannot render until it arrives.
  • No shared cache any more. The old argument was that a popular font was already cached from another site. Browsers now keep a separate cache per site, to stop sites from detecting each other through it: Safari since 2013, Chrome since version 86 in 2020, Firefox since version 85 in 2021. Every site downloads its own copy.
  • Every visit is reported. On every visit that misses the cache, the service sees the reader's IP address and browser, and, under the default referrer policy, the site's origin.

The privacy question

That point went to court. On 20 January 2022 the Munich Regional Court, in case 3 O 17493/20, ruled against a website owner who embedded Google Fonts from Google's servers without the visitor's consent. The court treated the visitor's dynamic IP address as personal data under the GDPR, held that sending it to Google had no legitimate interest behind it, because the fonts could have been used without contacting Google's servers by hosting them on the site itself, and noted the transfer to a server in the United States. It awarded the visitor €100 in damages and ordered the site to stop passing his IP address to Google.

The sum was small, but the reasoning was not, and a wave of mass demand letters to German site owners followed. The lesson for developers does not depend on the law of one country: a third-party font request is a disclosure of the reader's visit, made by the page on the reader's behalf. Privacy treats this as a quality of the site, not a legal box to tick.

Self-hosting removes the disclosure, the extra origins and the dependency, at the cost of writing the rules yourself. The license usually allows it, as the next section explains.

5. Licensing

A font is software, and its license decides where it may go. Buying a font for a desktop typically allows installing it on a few computers to make documents and images; it usually does not allow putting the file on a web server, where every visitor downloads a copy. That needs a web license, and type foundries write their own terms.

  • The SIL Open Font License (OFL), version 1.1 from 2007, is the common license for free fonts. It allows using, studying, modifying and redistributing the font, including embedding it in web pages and bundling it with software. The font may not be sold on its own, modified versions must stay under the OFL, and a font may declare Reserved Font Names that modified versions must not use. IBM Plex, Inter, Fraunces, Literata and every font in this section's demos are under the OFL, and each has its license file beside it.
  • Commercial web licenses vary: priced by page views or by domain, sometimes forbidding subsetting or conversion. Some come only as a service: Adobe Fonts is part of a subscription and is served from Adobe's servers.
  • System fonts are licensed to the reader, not to you. A font stack may name Helvetica and get it where it is installed; copying the file to a server is a different act, which is why this section shows such faces as pictures.

The habit: before a font goes into a project, read what its license says about web use, self-hosting and modification, and keep a copy with the files. A design built on a font the client cannot license for the web will have to change.

6. Subsetting and unicode-range

A full font covers far more characters than a typical page uses. Inter's complete release includes Cyrillic, Greek, Vietnamese and a long list of symbols; an English page needs about two hundred characters. Subsetting builds a smaller file holding only the glyphs you need, with fonttools' pyftsubset or a wrapper such as glyphhanger:

pyftsubset Inter.ttf \ --unicodes="U+0000-00FF,U+2013-2014,U+2018-201E,U+2022,U+2026,U+20AC" \ --layout-features="*" \ --flavor=woff2 \ --output-file=inter-latin.woff2

--layout-features="*" keeps all of GSUB and GPOS, so kerning and ligatures survive. Under the OFL a subset is a modified version: allowed, but the OFL FAQ says it "would not normally allow the use of" a Reserved Font Name, unless the subset stays functionally equivalent to the original or the authors agree. Inter, Fraunces, Literata and Recursive reserve no names, so this section subsets them freely. IBM Plex reserves "Plex" and Source Serif reserves "Source", which is why this section's demos use Literata rather than a Source Serif subset, and why this site serves IBM's complete, unmodified Plex files rather than smaller subsets (section 12 weighs what that costs). The extreme case is in this section's own files: the Chinese sample font for the script sampler holds the 21 characters it displays in 6,092 bytes, cut from a Noto Sans SC source of 15,054,748 bytes. Chinese, Japanese and Korean fonts hold tens of thousands of glyphs, which is why they are almost always subset or split.

Splitting with unicode-range

Cutting characters is risky when you do not know the text in advance: a name with a ŧ, a quotation in Polish or a reader's comment falls back to another font in the middle of a word. unicode-range avoids the choice. Split the font into several files, give each its own rule with the same family name and a range, and the browser downloads only the files whose ranges the page's text actually uses:

@font-face { font-family: "Inter"; src: url("/fonts/inter-latin.woff2") format("woff2"); font-weight: 100 900; font-display: swap; unicode-range: U+0000-00FF, U+2013-2014, U+2018-201E, U+2022, U+2026, U+20AC; } @font-face { font-family: "Inter"; src: url("/fonts/inter-latin-ext.woff2") format("woff2"); font-weight: 100 900; font-display: swap; unicode-range: U+0100-024F, U+1E00-1EFF; } @font-face { font-family: "Inter"; src: url("/fonts/inter-cyrillic.woff2") format("woff2"); font-weight: 100 900; font-display: swap; unicode-range: U+0400-04FF; }

An English page fetches the first file; a page that quotes Dvořák also fetches the second; a Russian page fetches the third. This is how font services split their fonts, and self-hosted fonts can do the same. Keep each script's letters, accents and punctuation in the same file, so features that combine them still work.

7. Invisible text and flashes: FOIT and FOUT

Two acronyms name the two visible symptoms of a late font. A flash of invisible text (FOIT) is the browser drawing nothing where the text will be, so the reader sees layout, images and blank lines. A flash of unstyled text (FOUT) is the browser drawing the text in a fallback font and then redrawing it when the web font arrives: readable at once, but visibly changing.

For years browsers disagreed: Internet Explorer drew the fallback at once and swapped later, while Chrome, Firefox and Safari hid text for up to three seconds first. The CSS Fonts specification records that table and concludes that no single behavior suits every page. Neither symptom is always worse: a FOIT on body text stops reading, while a FOUT on a logo briefly shows a logo that is not the logo. Authors needed a way to say which mattered for each font, and that is font-display.

8. font-display

CSS Fonts Level 4 models the wait as a font display timeline that starts when the browser first tries to use a font face, in three periods:

  1. the block period: if the font is not loaded, text is drawn with an invisible fallback; if it loads, it is used;
  2. the swap period: if the font is not loaded, text is drawn in a visible fallback; if it loads, it is swapped in;
  3. the failure period: if the font has not loaded by now, it is treated as failed and the fallback stays.

The font-display descriptor sets the length of each period for one @font-face rule:

The font display timeline for each font-display value, over six secondsA time axis from 0 to 6 seconds, with one bar per value. auto, which most browsers treat like block, and block: invisible text for 3 seconds, then a fallback that swaps when the font arrives, with no end. swap: invisible for at most 100 milliseconds, then a fallback that swaps whenever the font arrives. fallback: invisible for at most 100 milliseconds, a fallback that can swap until about 3.1 seconds, then the fallback for good. optional: the font is used only if it is ready for the first paint; otherwise the fallback for good. A dashed line at 2 seconds marks a font that arrives then: auto, block, swap and fallback show it; optional does not.auto(browser's choice)usually invisibleblockinvisible, about 3 sfallback, swaps on arrivalswapfallback, swaps whenever the font arrivesfallbackfallback, can swapfallback for goodoptionalused only if ready for first paint01 s2 s3 s4 s5 s6 sa font arriving at 2 sblock period: invisibleswap period: fallback, may swapfailure period: fallback stays
Block, swap and failure periods for each value, with the spec's recommended timings. A font arriving at 2 s ends the wait in every row but optional.
font-display values, from CSS Fonts Level 4
ValueBlock periodSwap periodThe spec's intended use
autoThe browser's own policy; the spec notes that many browsers behave like blockThe default when the descriptor is left out
blockShort (3 s recommended)InfiniteOnly for small pieces of text that are useless in any other font
swapExtremely small (100 ms or less)InfiniteSmall pieces of text where the font matters but any font gets the message across, such as a logo
fallbackExtremely small (100 ms or less)Short (3 s recommended)Body text and other long text, where the font is wanted but a late swap would disturb a reader already reading
optionalThe font is used only if it can be used for the text's first paint; otherwise both periods count as expired, and the browser may even cancel or deprioritize the downloadBody text where the font is a nice-to-have; returning visitors, with the font cached, get it

The spec's advice is more careful than the common habit of putting swap on everything. For body text it recommends fallback or optional, because a reader who has started reading a paragraph is disturbed when it reflows under their eyes, and a swap after three seconds is more likely to interrupt reading than to help it. swap earns its place on short text whose look carries meaning, and block, which hides text for seconds, is meant for almost nothing. The timings are recommendations: browsers may choose others, and a browser may let its user override them, for example by setting every block period to zero.

Older descriptions give optional a 100 ms block period and no swap period, and browsers behave much that way, but the current spec defines it by the first paint. Chrome, for example, briefly holds the first paint for a preloaded optional font.

9. Preloading fonts

Fonts are discovered late by design. The browser cannot know a font is needed until it has the HTML, has fetched and parsed the CSS, found an @font-face rule, matched it to an element through font-family, and laid out text that uses it. Only then does the request start, often after every style sheet and the first images. A preload in the head announces the file while the HTML is still being parsed:

<head> <link rel="stylesheet" href="/css/site.css"> <link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin> </head>
A font found through the CSS, compared with a preloaded fontTwo request waterfalls on the same time axis. Top, without preload: the HTML downloads; the CSS request starts once the HTML names it; the font request starts only after the CSS has arrived and layout finds text that needs the font; the text appears in the web font late. Bottom, with preload: the HTML downloads; the font request starts as soon as the preload link is parsed, alongside the CSS; when layout needs the font, it has already arrived, and the text appears in the web font with the first paint.Found through the CSSHTMLCSSfontlayout needs the fonttext in the web fontPreloadedHTMLCSSfonttext in the web font with the first painttime
Without a preload the font waits for the CSS and for layout. With one, it travels alongside the CSS and is often ready when the text is first drawn.

The attributes all matter:

  • as="font" gives the request a font's priority and lets the browser match it to the later request from the CSS.
  • type="font/woff2" lets a browser that cannot use the format skip the download.
  • crossorigin is required, even for a font on your own origin. The CSS Fonts specification fetches every font in CORS mode, so a font request is always a CORS request. A <link rel="preload"> without crossorigin makes an ordinary no-CORS request, which does not match; the browser cannot reuse it, downloads the font a second time, and warns in the console about an unused preload. With the bare attribute (meaning anonymous) the two requests agree.

What a preload costs

A preload is a claim that this file matters more than whatever the browser would otherwise fetch first, and every claim competes with the CSS, the scripts and the hero image of the page (Image Performance makes the same point for images). So:

  • Preload only what the first screen uses, usually the body face, perhaps the heading weight: one or two files. Preloading italic, bold italic and the monospace face for a code sample below the fold spends early bandwidth on text no one sees yet.
  • Preload exactly the file the CSS will ask for. A different URL, a different subset or a stale version name downloads twice.
  • Remove preloads that no longer match. Chrome reports a preload not used within a few seconds of the load event; designs change and preloads outlive them.
  • Prefer self-hosting to preconnecting. A preload cannot name a font service's file in advance, since its URLs come from its CSS; preconnect is the most it can do.

The HTML side, including where preloads go in the head, is in The head and link relationships.

10. Layout shift and matched fallbacks

A swap does more than change the shape of letters. The fallback and the web font have different widths, so lines break in different places, and different vertical metrics, so lines may have different heights. When the font arrives, paragraphs grow or shrink and everything below them moves. That movement counts against Cumulative Layout Shift, the Web Vital described in Image Performance, and a reader who was halfway through a sentence has to find their place again.

Images fix their shift with width and height. Fonts fix theirs by making the fallback the same size as the web font. Four @font-face descriptors adjust a font, and they can be applied to a local font, so a rule can define a new family that is Arial, resized and given the web font's vertical metrics:

The metric descriptors
DescriptorWhat it setsWhat it fixes
size-adjustA multiplier on every glyph and every metric of the face, without changing font-size or emWidth: the same words take the same room, so lines break in the same places
ascent-overrideThe height above the baseline, as a percentage of the font sizeWhere the baseline sits in the line, and the height of a line with line-height: normal
descent-overrideThe depth below the baselineThe same, below the baseline
line-gap-overrideThe extra gap a font asks for between linesThe rest of a normal line's height

Computing the values

The values come from the fonts' tables. For Inter over Arial, the fallback demo below uses fonttools to read both:

  • Both fonts have 2,048 units per em. Inter's ascender is 1,984 units, its descender 494, its line gap 0. Arial's are 1,854, 434 and 67.
  • The demo's sample paragraph is 118.414 em wide in Inter (at optical size 16, the size it is shown at) and 111.615 em in Arial.
  • size-adjust is the ratio of widths: 118.414 ÷ 111.615 = 1.0609, or 106.09%.
  • The overrides are Inter's metrics, divided by that ratio, because the spec scales overrides by size-adjust too: ascent (1,984 ÷ 2,048) ÷ 1.0609 = 91.31%; descent (494 ÷ 2,048) ÷ 1.0609 = 22.74%; line gap 0%.
  • One nudge: Inter's ascent at 16 pixels is exactly 15.5, a half-pixel edge. Browsers round line metrics to whole pixels, and 91.31% leaves the fallback a hair under 15.5, so it rounds down and each normal line comes out a pixel short. The demo uses 91.5%, safely past the edge.
@font-face { font-family: "Inter fallback"; src: local("Arial"), local("ArialMT"), local("Liberation Sans"); size-adjust: 106.09%; ascent-override: 91.5%; /* 91.31%, nudged past the half-pixel edge */ descent-override: 22.74%; line-gap-override: 0%; } body { font-family: "Inter", "Inter fallback", sans-serif; }

Until Inter loads, the text is drawn in the adjusted Arial; when Inter arrives, the letters change shape but each line keeps its length and its height. Liberation Sans is listed because it was designed with Arial's widths and metrics and is common on Linux, where Arial often is not. A system without either falls through to sans-serif, unadjusted. Doing this arithmetic by hand once is worth it for understanding; in practice tools such as Capsize, Fontaine and Next.js's next/font compute the values from the font files.

The demo turned up two practical limits. Inter has an optical size axis and narrows a little as it grows, so a match computed at 16 pixels drifts at 32. And with line-height: normal, line heights depend on rounding, as the nudge showed; with line-height: 1.5, every line is the same height in any font, so the match needs no nudging. The numeric line height is the robust answer.

Browser support

size-adjust works in Chrome and Edge 92, Firefox 92 and Safari 17. The three overrides have been in Chrome and Edge since version 87 and Firefox since 89, but as of October 2026 Safari has them only in its Technology Preview. In Safari, then, a matched fallback fixes widths and line breaks but not vertical metrics, which is one more reason to set line-height as a number: with a fixed line height, width is most of the match, and Safari supports width.

11. The CSS Font Loading API

Everything so far is declarative: the browser decides when to load and swap. The CSS Font Loading API lets a script watch and steer the same machinery. It has been in every major browser for years, and it has three parts:

  • document.fonts, a FontFaceSet holding every font face the document knows, including those from @font-face rules. document.fonts.check("1rem Inter") says whether text in that font can be drawn now without a load, and document.fonts.load("1rem Inter") starts the load and returns a promise.
  • FontFace, a font face made in script. new FontFace(family, source, descriptors) takes the same descriptors as the CSS rule; its load() returns a promise, and document.fonts.add() makes it usable by CSS.
  • document.fonts.ready, a promise that resolves when the document has finished loading fonts, and the loadingdone and loadingerror events.
const display = new FontFace( 'Fraunces', 'url(/fonts/fraunces-var.woff2) format("woff2")', { weight: '100 900', display: 'swap' }, ); document.fonts.add(display); try { await display.load(); document.documentElement.dataset.fonts = 'loaded'; } catch { // The fallback stays; the page is still readable. } // Canvas text and measurements need the real font, not the fallback. await document.fonts.ready; const context = document.querySelector('canvas').getContext('2d'); context.font = '600 1.5rem Fraunces, serif'; const width = context.measureText('Quarterly results').width;

Before font-display, scripts like this were how sites avoided invisible text. Today the descriptor does that without script, so the API's main uses are canvas text, which does not wait for fonts by itself; measuring text; fonts chosen at run time; and labs like this page's, which read document.fonts to report each face's status.

12. This site's type, as a worked example

Every page of this course is set in IBM Plex Sans, with IBM Plex Mono for code, and the setup shows most of the choices above being made. The theme style sheet declares eight static faces. They are IBM's own complete WOFF2 files, unchanged, taken from IBM's release and self-hosted in folders named for that release:

@font-face { font-family: 'IBM Plex Sans'; font-style: normal; font-weight: 400; font-display: swap; src: url('/assets/fonts/ibm-plex-sans-1.1.0/IBMPlexSans-Regular.woff2') format('woff2'); } @font-face { font-family: 'IBM Plex Sans'; font-style: normal; font-weight: 600; font-display: swap; src: url('/assets/fonts/ibm-plex-sans-1.1.0/IBMPlexSans-SemiBold.woff2') format('woff2'); } @font-face { font-family: 'IBM Plex Mono'; font-style: normal; font-weight: 400; font-display: swap; src: url('/assets/fonts/ibm-plex-mono-2.5.0/IBMPlexMono-Regular.woff2') format('woff2'); } <link rel="stylesheet" href="/assets/css/powell-theme.css"> <link rel="preload" href="/assets/fonts/ibm-plex-sans-1.1.0/IBMPlexSans-Regular.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/assets/fonts/ibm-plex-sans-1.1.0/IBMPlexSans-SemiBold.woff2" as="font" type="font/woff2" crossorigin>

The folder name is what makes long caching safe. A file at /assets/fonts/ibm-plex-sans-1.1.0/ never changes; a new Plex release would go in a new folder, under new URLs. So the server can tell browsers to keep the files for a year and never ask about them again, with one rule in the _headers file that Cloudflare reads for static files:

/assets/fonts/* Cache-Control: public, max-age=31536000, immutable

max-age=31536000 is a year in seconds, and immutable tells the browser that even a reload need not check. Every other file on the site keeps the default, max-age=0, must-revalidate, which makes the browser ask the server on each use whether its copy is still current. That suits HTML and CSS, which change without changing their URLs. It is what the fonts had before, too, so every page view sent a request per font only to hear "not modified".

Measured on a cold load of Image Performance in Chrome against a local build, October 2026, the page requested seven of the eight files, 414,288 bytes in all: Plex Sans regular, italic, medium and semibold, and Plex Mono regular, medium and semibold. Bold 700 was declared but unused, so it was never downloaded. The two preloaded files started first, 13 milliseconds after navigation began, against 53 for the five found through the CSS. The course home page needed five files, 296,468 bytes. Loading either page a second time downloaded no font and sent no request for one.

Strengths

  • Self-hosted, WOFF2 only. No third-party request or disclosure, and the site works offline. The build even strips the Google Fonts imports in the Vanilla Breeze framework's style sheet.
  • IBM's files, unmodified. The name "Plex" stays with the files IBM made, as the license asks (section 6), and each folder carries IBM's license.
  • The whole character set. Each face has about 900 characters: accented Latin for most European languages and Vietnamese, Greek and Cyrillic. A student named Dvořák, Nguyễn or Ελένη stays in Plex.
  • Cached for a year. A reader pays for the fonts once; every later page, on any visit, draws in Plex at once with no request.
  • Only the weights in use, so a page downloads only the faces its text asks for, and narrow preloads: only the body and heading faces.
  • Fallbacks end in generic families, through ui-sans-serif and system-ui to sans-serif, so text is never at the mercy of one file.

Trade-offs

  • A heavy first visit. Complete files are nearly three times the size of the Latin subsets the site used before: about 63 KB a face against 23. A first visit costs 300 to 415 KB of fonts, well over the budget in the next section. The site accepts that for clean licensing and full coverage, paid once a year per reader; a site whose readers mostly come once would decide the other way.
  • A smaller option exists. IBM also publishes its own split files, one per script, with a style sheet that joins them through unicode-range. They are IBM's originals too, so they keep the name, and an English page would fetch only the Latin file, about 21 KB a face. The cost is six files and rules a face instead of one.
  • swap on long text. Every face uses swap, so on a slow first visit the reader sees system type, then Plex. The spec would suggest fallback for body text; the site trades a possible late swap for always ending up in its own face, which caching makes the usual case.
  • No matched fallback. The system UI font is unadjusted, so a swap can rewrap lines; with the body faces preloaded, it usually happens before the first paint.
  • Seven requests, not one. Static weights keep each file smaller, but a page with code samples and italics makes seven requests. A variable font would be one larger file; neither is always better, and Variable fonts, page 08, does the comparison.

Complete files also reward measuring formats: IBM's Plex Sans Regular is 63,020 bytes as WOFF2 and 87,508 as WOFF, 28 percent smaller. On a tiny subset the gap nearly disappears, but WOFF2 is never much worse, so it is the right single format.

13. A font budget

A performance budget sets limits in advance and checks them on every change (an image budget works the same way). Fonts are easy to budget because they are few, and easy to break because each new weight looks harmless. A font budget has three lines:

  • Families: usually one or two, and a system stack counts as zero bytes. Choosing and pairing makes the design case for few typefaces; the performance case is the same.
  • Files on the first screen: the faces the first view of a typical page needs, ideally two to four, with at most two preloaded.
  • Bytes: a total for the first view. On a Latin site, 100 to 150 KB of WOFF2 buys a family in several weights or one variable font, and anything over that should have a reason.

The budgets on these pages

This site breaks its own byte line on purpose: a first view of an ordinary page costs 300 to 415 KB of Plex, and later views cost nothing, for the reasons in section 12. The Fonts section adds rules of its own, enforced by its test: a lesson page loads none of the section's demo fonts itself, each demo may declare at most 750 KB, and every demo font file must stay under 150 KB, except the script fonts for the sampler, which keep whole Unicode blocks so shaping works. This page loads none: its comparisons live in the two demos, which load Fraunces (121 KB) and Inter (73 KB) only when opened.

Bytes are a means. What the reader feels is time to readable text, which depends on when the font is discovered (section 9) and what is drawn meanwhile (section 8), and stability, which depends on the fallback (section 10). Budget the outcomes too: no invisible text on the first screen, and no layout shift from a font swap.

14. Summary

Web fonts and performance, in brief
ConceptKey points
The costWhile a font loads, the browser hides the text, shows a fallback and swaps, or keeps the fallback; each costs something.
@font-faceDescribes a face: family name, src, matching descriptors (weight, style, width, unicode-range) and behavior (font-display, metric overrides). Files download only when text uses them. In CSS2 (1998), dropped from CSS 2.1, back from 2008.
FormatsA font is tables: cmap, glyf or CFF, GSUB, GPOS, hinting, metrics. EOT (Internet Explorer) and SVG fonts (early iPhones) are history; WOFF (2012) and WOFF2 (2018) wrap the same tables compressed. Serve WOFF2 alone.
Hosting and privacyServices add origins and a third-party request per visit; caches are per site, so nothing is shared. A Munich court (3 O 17493/20, 2022) found sending the IP address to Google unjustified when self-hosting was possible.
LicensingA desktop license is not a web license. The OFL allows self-hosting and modification; a subset is a modified version, which should not keep a Reserved Font Name. Commercial terms vary; system fonts belong to the reader's system.
SubsettingKeep only the glyphs you need with pyftsubset, keeping layout features. Split by script with unicode-range so pages fetch only what their text uses.
FOIT, FOUT, font-displayBlock, swap and failure periods. block: about 3 s invisible, then swap; swap: fallback at once, swap any time; fallback: swap within about 3 s or never; optional: the font only if ready for first paint. The spec suggests fallback or optional for body text.
PreloadFonts are found late; preload the one or two the first screen needs, with as="font", type and crossorigin, which fonts need even from the same origin because they are fetched in CORS mode.
Layout shiftMatch the fallback to the font: size-adjust for widths, ascent-override, descent-override and line-gap-override for vertical metrics, computed from the fonts' tables. Safari has size-adjust but not yet the overrides; a numeric line-height makes width most of the match.
Font Loading APIdocument.fonts, FontFace, fonts.ready: for canvas text, measurement and run-time fonts.
CachingPut the release in the font URL, then serve the files with Cache-Control: public, max-age=31536000, immutable: readers pay once, and later pages send no request.
A budgetFew families, two to four files on the first screen, at most two preloads, a byte limit checked automatically, and outcomes: no invisible text, no shift.