completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Smaller, faster fonts

Make web fonts arrive sooner: WOFF2, Latin subsets and unicode-range, one variable file for every weight, preloading with crossorigin, and self-hosting.

By the end, you can
  • 01
    Shrink a font fileConvert it to WOFF2, keep only the alphabets your pages use, and replace several weights with one variable file.
  • 02
    Start the right font earlyPreload the one font the first view needs, with crossorigin, and know what a preload costs.
  • 03
    Choose where fonts come fromSay what a font service does for you, what it costs, and what you take on when you self-host.

A font that took almost five seconds

In the last lesson, Ravi copied his font's CSS from a blog post. It came with one file, caveat.woff2. This week he adds his tip to the mint tea page, in bold, under the description. First he wants to know what he's sending to his visitors. So he starts again from Caveat's own download.

He downloads Caveat from a font website. He gets one TTF file for each weight. TTF, short for TrueType font, is the format the website gives him. A weight is how thick the letters are: 400 is regular and 700 is bold. Ravi takes two files: regular for the words, and bold for the tip. They are 256,968 and 257,316 bytes, about a quarter of a megabyte each.

Asha opens the page on her phone, on a slow mobile link. The words come at once, in her phone's own font. That's what font-display: swap promised in the last lesson. Then she waits. Caveat arrives almost five seconds after she opened the page. With the blog post's file, it came at 2.2 seconds. Same font, same phone. Why is the font website's version so slow? And can Caveat come sooner than both?

The course loaded the page three times in Chrome 157, on a phone's screen. It used DevTools' Slow 4G network, and made the CPU 4 times slower, as in the last lesson. It kept every frame Chrome drew. Then it did the same for the page this lesson ends with.

The same page, before and afterPop out in the code editor (opens in a new tab)JavaScript
// The mint tea page on Asha's phone, as recorded (ms after she opened the page)const fromTheWebsite = { files: 2, bytes: 514284, words: 1250, caveat: 4683 };const finished = { files: 1, bytes: 74932, words: 1289, caveat: 1605 }; console.log(`From the website: ${fromTheWebsite.bytes} bytes in ${fromTheWebsite.files} files, Caveat at ${fromTheWebsite.caveat} ms`);console.log(`Finished: ${finished.bytes} bytes in ${finished.files} file, Caveat at ${finished.caveat} ms`);
  • Line 1 is a comment. Times are in milliseconds (ms) after Asha opened the page. They come from the load whose fonts arrived in the middle of the three.
  • Line 2 is Ravi's page with the two TTF files. It holds how many font files there were and their bytes. Then it holds when the words appeared, and when the page was all in Caveat.
  • Line 3 is the finished page: one file of 74,932 bytes.
  • Lines 5 and 6 print From the website: 514284 bytes in 2 files, Caveat at 4683 ms and Finished: 74932 bytes in 1 file, Caveat at 1605 ms.

The finished page sent less than a sixth of the bytes. Caveat came more than three seconds sooner. Here is what Asha's screen showed:

The mint tea page on Asha's phone, on Slow 4G. Two TTF files from the font website: 514,284 bytes: 1,250 ms, the words, in the phone's font; 4,683 ms, all in Caveat. One small file, preloaded: 74,932 bytes: 1,289 ms, the words, in the phone's font; 1,605 ms, all in Caveat.The words appearAll in CaveatTwo TTF files from the font website: 514,284 bytesThe mint tea page at 1,250 ms: the words, in the phone's font1,250 msthe words, in the phone's fontThe mint tea page at 4,683 ms: all in Caveat4,683 msall in CaveatOne small file, preloaded: 74,932 bytesThe mint tea page at 1,289 ms: the words, in the phone's font1,289 msthe words, in the phone's fontThe mint tea page at 1,605 ms: all in Caveat1,605 msall in Caveat
Asha's screen as Chrome drew it. Both pages showed the words at once in her phone's font. With the files from the font website, Caveat took more than three seconds more. With one small, preloaded file, it took about a third of a second.

Look at the left column. The words appeared at about the same moment on both pages. What changed is how long they stayed in the phone's font: 3,433 ms on the first page, and 316 ms on the finished one.

Real-life analogyA lighter parcel of chalk pens, posted sooner

Ravi's chalk pens come by post. The supplier can send a big box: every colour they sell, loosely packed, one pen for each thickness. Or it can send a slim parcel: only the colours his board uses, packed flat, with one pen that writes thin or thick. The slim parcel arrives sooner. It arrives sooner still if Ravi orders first thing in the morning.

In real life: A big box with every colour the supplier sells
In fonts: A font file with every letter Caveat has
In real life: Packing the pens flat in a slim box
In fonts: WOFF2: the same font, squeezed
In real life: Only the colours the board uses
In fonts: A subset: only the letters your pages use
In real life: One pen that writes thin or thick
In fonts: A variable font: every weight in one file
In real life: Ordering first thing in the morning
In fonts: A preload: asking for the font with the HTML
The main idea

A web font arrives sooner when it's smaller and asked for earlier. Send WOFF2, keep only the alphabets your pages use, put every weight in one variable file, and preload the one font the first view needs. The first view is what the visitor sees before scrolling. Each step is small. Together, they took Caveat from almost five seconds to 1.6.

The rest of the lesson takes one step at a time:

  • Why is a TTF file so big? WOFF2: the same font in a smaller box.
  • Does the page need every letter? Only the letters you need.
  • One file or one per weight? One variable font or a file per weight.
  • How can the font start sooner? Preloading the font the first view needs.
  • Where should the files live? Self-hosting or a font service.

For the same ideas without a stopwatch, see Web fonts in the system design course. For how Next.js does these steps for you, see Optimizing fonts.

WOFF2: the same font in a smaller box

Here are Ravi's first two rules, one for each TTF file:

Two TTF files, as the font website gave themCSS
@font-face {  font-family: "Caveat";  src: url("caveat-regular.ttf") format("truetype");  font-display: swap;}@font-face {  font-family: "Caveat";  src: url("caveat-bold.ttf") format("truetype");  font-weight: 700;  font-display: swap;}
  • Lines 1 to 5 describe the regular file. Line 3 names caveat-regular.ttf, in the truetype format.
  • Lines 6 to 11 describe the bold file. Line 9, font-weight: 700, says it's for bold text, like Ravi's tip.
  • Both keep font-display: swap, the last lesson's choice.

What's inside a font file? Mostly letter shapes. Each shape is called a glyph. The file also holds tables of measurements, like how wide each letter is. A TTF file stores all of them as they are.

WOFF, the Web Open Font Format, is the same font with each table squeezed. It uses DEFLATE, the method gzip uses, from Text compression. WOFF2 does two things instead. First it rewrites the biggest tables, like the glyph shapes, in a form that packs better. Then it squeezes them with Brotli, a stronger method. The WOFF2 standard says this gives "improved compression and thus lower use of network bandwidth, while still allowing fast decompression even on mobile devices."

Real-life analogyFlat-pack furniture

A shop can deliver a wardrobe already built, in a big box. Or it can take the wardrobe apart, stack the flat pieces tightly, and send a slim box. At home, you put it back together. It's the same wardrobe, with every shelf in its place.

In real life: The wardrobe
In fonts: The font, with every glyph and table
In real life: Delivered already built
In fonts: TTF: the tables as they are
In real life: Taken apart and stacked tightly
In fonts: WOFF2: tables rewritten, then squeezed with Brotli
In real life: Putting it back together at home
In fonts: The browser unpacking the WOFF2 file

Nothing is lost. The standard says the unpacked font "will retain the exact functionality of the input font file". Converting takes a few lines. Ravi uses fontverter, a small npm package. For WOFF2, it runs Google's own WOFF2 encoder.

Converting the TTF to WOFF2JavaScript
import { readFileSync, writeFileSync } from "node:fs";import fontverter from "fontverter"; const ttf = readFileSync("Caveat_400Regular.ttf");const woff2 = await fontverter.convert(ttf, "woff2");writeFileSync("caveat-regular.woff2", woff2);console.log(`${ttf.length} bytes -> ${woff2.length} bytes`);
  • Lines 1 and 2 load Node's file functions and fontverter.
  • Line 4 reads the regular TTF file from the font website.
  • Line 5 converts it to WOFF2. Line 6 saves the new file.
  • Line 7 prints 256968 bytes -> 102596 bytes.

The course made both files in both web formats, and compared the sizes:

Caveat regular in three formatsPop out in the code editor (opens in a new tab)JavaScript
// Caveat regular in three formats, and the TTF squeezed by a server with Brotli (bytes)const ttf = 256968;const woff = 134460;const woff2 = 102596;const ttfWithBrotli = 114311; console.log(`WOFF2 is ${Math.round((1 - woff2 / ttf) * 100)}% smaller than the TTF`);console.log(`WOFF2 is ${Math.round((1 - woff2 / woff) * 100)}% smaller than WOFF`);console.log(`WOFF2 is ${ttfWithBrotli - woff2} bytes smaller than the TTF sent with Brotli`);
  • Lines 2 to 4 hold the regular file's size as TTF, WOFF and WOFF2. Line 5 is the TTF squeezed with Brotli by a server, as a server can do for any file it sends.
  • Line 7 prints WOFF2 is 60% smaller than the TTF.
  • Line 8 prints WOFF2 is 24% smaller than WOFF. The WOFF2 report measured a middle value of 23.94% on Google's fonts, so Caveat is typical.
  • Line 9 prints WOFF2 is 11715 bytes smaller than the TTF sent with Brotli.

Line 9 answers a fair question. Why not let the server squeeze the TTF? Because WOFF2 rewrites the tables first. The same Brotli, on the plain TTF, still left 11,715 more bytes.

Ravi points his two rules at caveat-regular.woff2 and caveat-bold.woff2, with format("woff2"). On Asha's phone, the files arrived at 2,928 and 2,960 ms, instead of 4,651 and 4,659. The page was all in Caveat at 2,974 ms. Pick each step to see its frames:

Playground: Ravi's page, step by step
What Asha saw
  1. The page at 1,250 ms: the phone's font1,250 ms: the phone's font
  2. The page at 4,671 ms: Caveat, the tip still waiting4,671 ms: Caveat, the tip still waiting
  3. The page at 4,683 ms: all in Caveat4,683 ms: all in Caveat
  1. Font filescaveat-regular.ttf, caveat-bold.ttf
  2. Their bytes514,284
  3. Font asked for1,172 ms, by the stylesheet
  4. Last font arrived4,659 ms
  5. Words readable1,250 ms
  6. All in Caveat4,683 ms
Try it yourself
Two TTF files

514,284 bytes of font. Asha saw the page all in Caveat at 4,683 ms.

Recorded in Chrome 157, on a phone's screen, with DevTools' Slow 4G and a CPU 4 times slower. Each strip is the load whose last font arrived in the middle, of 3. Times are in ms after Asha opened the page.

Why it matters: web.dev quotes the 2022 Web Almanac: "WOFF2 is now supported everywhere." So it's the only format you need. On Asha's phone, that one change saved 1.7 seconds.

Only the letters you need: subsets and unicode-range

Ravi's WOFF2 files still hold every letter Caveat has. A character is one letter, digit or sign, like a, 7 or ?. The regular file has 1,011 glyphs for 754 characters. They cover the Latin and Cyrillic alphabets, with their accented letters. The mint tea page uses 36 different characters.

A subset is a copy of a font with only some of its characters. It's like carrying a phrasebook instead of the whole dictionary. The usual cut is by alphabet: keep the Latin letters, digits and signs, and leave the rest out. Ravi uses subset-font, an npm package. It does the work with HarfBuzz, a font library built to run in Node.

Keeping only the Latin charactersJavaScript
import { readFileSync, writeFileSync } from "node:fs";import subsetFont from "subset-font";import { LATIN_RANGE, charactersIn } from "./font-optimization-examples.ts"; const ttf = readFileSync("Caveat_400Regular.ttf");const latin = await subsetFont(ttf, charactersIn(LATIN_RANGE), { targetFormat: "woff2" });writeFileSync("caveat-latin-regular.woff2", latin);console.log(`${ttf.length} bytes -> ${latin.length} bytes`);
  • Line 3 imports two things from this lesson's own code. LATIN_RANGE is the Latin range of Caveat's Fontsource files, below. charactersIn turns it into a string with one of each character.
  • Line 6 asks subset-font to keep only those characters, and to save the result as WOFF2.
  • Line 8 prints 256968 bytes -> 49832 bytes.

The Latin subset is less than half the full WOFF2 file: 49,832 bytes against 102,596. Ravi's two subsets come to 101,904 bytes, about what one full WOFF2 file was. On Asha's phone, the page was all in Caveat at 2,369 ms. That's 605 ms sooner than with the full WOFF2 files.

Could Ravi keep only the page's own 36 characters? The course tried. That file is 18,132 bytes. But Ravi will change the tip and the steps. The CSS Fonts standard picks a font "for each character". If the subset lacks a character, the browser takes the next font in the list. So a new letter would show in the phone's font, in the middle of a Caveat word. Subset by alphabet, unless the text never changes, like a logo.

One file for each alphabet

Ravi adds the tea's Ukrainian name under the heading: М’ятний чай. Now some visitors need Cyrillic letters, and most don't. The answer is several subset files, and a rule for each that says which characters it covers. That's the unicode-range descriptor. Unicode gives every character a number, its code point, written like U+0410. A unicode-range lists code points, one by one or in ranges.

Caveat already comes in four such files on Fontsource, which puts open fonts in npm packages. They are latin, latin-ext (more accented Latin letters), cyrillic and cyrillic-ext. Here is the Cyrillic file's rule:

The Cyrillic file and its rangeCSS
@font-face {  font-family: "Caveat";  src: url("caveat-cyrillic.woff2") format("woff2");  font-weight: 400 700;  font-display: swap;  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;}
  • Line 3 names the file, caveat-cyrillic.woff2.
  • Line 4 says this one file covers every weight from 400 to 700. The next section explains how.
  • Line 6 is the unicode-range. U+0400-045F is the main Cyrillic alphabet. The rest are a few more letters and signs.
  • The other three rules look the same, each with its own file and range.

The standard is strict about these ranges: "User agents must not download or use the font for codepoints outside this set." A user agent is the browser. So the browser looks at the text, and fetches only the files whose range covers a character on the page.

Real-life analogyA menu for each language

A café keeps its menu in four languages. The waiter doesn't hand every table all four. He sees who's sitting there, and brings only the menus they can read. A table that reads two languages gets two menus.

In real life: The four menus
In CSS: Caveat's four subset files
In real life: The language printed on each cover
In CSS: Each rule's unicode-range
In real life: Seeing who's at the table
In CSS: Chrome reading the characters on the page
In real life: Bringing only the menus they can read
In CSS: Fetching only the files the text needs

The course loaded the page with all four rules twice: once in English, and once with the Ukrainian name.

One page, two alphabetsPop out in the code editor (opens in a new tab)JavaScript
// The four-file page on Asha's phone, as recorded (ms after she opened the page)const english = { files: ["caveat-latin.woff2"], caveat: 2217 };const ukrainian = { files: ["caveat-latin.woff2", "caveat-cyrillic.woff2"], caveat: 2650 }; console.log(`English: ${english.files.join(", ")}, Caveat at ${english.caveat} ms`);console.log(`With Ukrainian: ${ukrainian.files.join(", ")}, Caveat at ${ukrainian.caveat} ms`);
  • Line 2 is the English page. Chrome fetched only caveat-latin.woff2, and the page was all in Caveat at 2,217 ms.
  • Line 3 is the page with the Ukrainian name. Chrome also fetched caveat-cyrillic.woff2, 77,216 more bytes.
  • Lines 5 and 6 print one line for each page.

The English page fetched one file of four, and the Ukrainian page two. The frames show one more thing. At 2,629 ms, the heading and the words were in Caveat, but the Ukrainian name was still in the phone's font. Each file swaps in on its own, when it arrives:

Caveat in four files, one per alphabet, each with a unicode-range. The English page: fetched latin. All in Caveat at 2,217 ms. With the Ukrainian name, at 2,629 ms: fetched cyrillic and latin. Its line waited for its file: all Caveat at 2,650 ms.The English pageThe page at 2,217 ms: all in Caveatlatin: 74,932latin-ext: 29,472cyrillic: 77,216cyrillic-ext: 42,856Fetched: only latinAll in Caveat at 2,217 msWith the Ukrainian name, at 2,629 msThe page at 2,629 ms: Caveat, one line still waitinglatin: 74,932latin-ext: 29,472cyrillic: 77,216cyrillic-ext: 42,856Fetched: latin and cyrillicIts line waited for its file: all Caveat at 2,650 ms
The English page fetched one file of four. The Ukrainian name needed Cyrillic letters, so Chrome fetched the Cyrillic file too, and that line stayed in the phone's font until it came.

Try it in your own browser. The playground adds Caveat's four files with their ranges, and draws your text with them. Type English, then a Polish city like Łódź, then some Cyrillic.

Playground: which files does your text need?
Your text, in "Caveat Subsets Lab", sans-serif

Mint tea

Caveat's four files in this browser
  1. caveat-latin.woff2, 74,932 bytesnot downloaded
  2. caveat-latin-ext.woff2, 29,472 bytesnot downloaded
  3. caveat-cyrillic.woff2, 77,216 bytesnot downloaded
  4. caveat-cyrillic-ext.woff2, 42,856 bytesnot downloaded
Try it yourself
Needs: latin

This runs in your own browser. It adds Caveat's four files as font faces, each with its unicode-range, and asks for your text. Only the files whose range covers one of your characters are downloaded. A file can come from your browser's cache.

Caveat's four variable subsets, as Fontsource serves them. Reset starts again with fresh font faces.

Why it matters: split a font by alphabet, and each visitor downloads only the alphabets on the page. The Ukrainian name cost 77,216 bytes, and only on the page that shows it.

One variable font or a file per weight

Ravi still sends two files, one for regular and one for bold. Each is a static font: one weight per file. A variable font holds a whole range of weights in one file. The OpenType font standard says variations let a designer "incorporate multiple font faces within a font family into a single font resource."

How can one file hold every weight? It stores each letter once, at its default weight: 400 for Caveat. Then it stores how far each point of the letter's outline moves for heavier weights. The standard calls these moves deltas. For a weight in between, the browser works out the shape from the deltas. The range of weights is the weight axis. Caveat's goes from 400 to 700.

Real-life analogyOne adjustable spanner instead of a set

A set of spanners has one for each size of nut. An adjustable spanner has a wheel: turn it, and it fits any size in its range. It's heavier than any one spanner in the set. But if you need three sizes, it's lighter than carrying three.

In real life: One spanner for each size
In fonts: One static file for each weight
In real life: The adjustable spanner
In fonts: One variable font
In real life: Turning its wheel
In fonts: Asking for any font-weight from 400 to 700
In real life: Heavier than one spanner, lighter than three
In fonts: Bigger than one static file, smaller than several

The rule for a variable file names a range of weights instead of one:

One file for every weightCSS
@font-face {  font-family: "Caveat";  src: url("caveat.woff2") format("woff2");  font-weight: 400 700;  font-display: swap;}
  • Line 3 names one file, caveat.woff2.
  • Line 4, font-weight: 400 700, says this file covers every weight from 400 to 700. The CSS Fonts standard accepts a range here "in place of a single value".
  • The words ask for 400 and the tip asks for 700. Both come from this one file.

That file is the blog post's caveat.woff2 from the last lesson. It's Caveat's variable Latin file from Google Fonts, the same one Fontsource ships. So the blog post's file had already been through the steps so far: WOFF2, a Latin subset, and one file for both weights.

Two files or onePop out in the code editor (opens in a new tab)JavaScript
// Caveat, Latin only, as WOFF2 (bytes)const twoFiles = 49832 + 52072;const variable = 74932; console.log(`Two files: ${twoFiles} bytes, one variable file: ${variable} bytes`);console.log(`The variable file saves ${twoFiles - variable} bytes and 1 request`);
  • Line 2 adds up Ravi's two Latin subsets. Line 3 is the variable file.
  • Line 5 prints Two files: 101904 bytes, one variable file: 74932 bytes.
  • Line 6 prints The variable file saves 26972 bytes and 1 request.

On Asha's phone, the page was all in Caveat at 2,221 ms, 148 ms sooner than with two subsets. Here is how far the four steps took Ravi's bytes:

The bytes of Ravi's font files after each step. Two TTF files: 514,284 bytes, all in Caveat at 4,683 ms. Two WOFF2 files: 210,196 bytes, all in Caveat at 2,974 ms. Two Latin subsets: 101,904 bytes, all in Caveat at 2,369 ms. One variable file: 74,932 bytes, all in Caveat at 2,221 ms.Two TTF files514,284 bytesAll in Caveat at 4,683 msTwo WOFF2 files210,196 bytesAll in Caveat at 2,974 msTwo Latin subsets101,904 bytesAll in Caveat at 2,369 msOne variable file74,932 bytesAll in Caveat at 2,221 ms
Each step made the font smaller, and Caveat came sooner on Asha's phone. The last bar is the same file the blog post gave Ravi.

A variable file isn't always smaller. web.dev warns that variable fonts "typically have larger file sizes than individual non-variable fonts that only contain one style." Google Fonts' static Latin file for regular alone is 48,836 bytes, less than the variable one. Drag the slider to see where the balance tips:

Playground: one variable file or a file per weight
Bytes for this page
  1. 2 static files99,856 bytes
  2. 1 variable file74,932 bytes
Try it yourself
The variable file is fewer bytes

Static: 2 files, 99,856 bytes. Variable: 1 file, 74,932 bytes.

Caveat's Latin files as WOFF2, all made by Google Fonts (Fontsource's copies): one static file per weight, or one variable file for every weight from 400 to 700.

Why it matters: if your pages use two or more weights, one variable file is usually fewer bytes, and always fewer requests. If they use one weight, a static file is smaller. Lesson 20.4, When to skip web fonts, asks how many weights you really need.

Preloading the font the first view needs

The file is small now. But it still starts late. In the last lesson, the font waited for the HTML, then the stylesheet, then the styles. On the variable page, the stylesheet arrived at 1,168 ms, and Chrome asked for the font at 1,172 ms.

A preload names a file in the HTML itself, so the browser can ask for it at once. You met preloads in Resource hints and in Remove render delay. Here is Ravi's, in the page's head:

A preload for the fontHTML
<link rel="preload" href="caveat.woff2" as="font" type="font/woff2" crossorigin><link rel="stylesheet" href="styles.css">
  • Line 1 is the preload. rel="preload" says "fetch this now", and href names the file. as="font" says what kind of file it is, so it gets a font's priority.
  • type="font/woff2" names the format. A browser that can't read WOFF2 skips the preload.
  • crossorigin asks for the file the way fonts are always asked for. The next part explains why that matters.
  • Line 2 is the stylesheet, as before. Its @font-face rule still describes the font.
Real-life analogyCalling the pharmacy ahead

Your doctor writes a prescription. You could walk to the pharmacy with it, and wait while they make it up. Or the doctor's office can call the pharmacy while you put your coat on. When you arrive, it's ready on the counter.

In real life: The prescription in your pocket
In HTML: The @font-face rule, inside the stylesheet
In real life: Walking there, then waiting
In HTML: Fetching the stylesheet, then the font
In real life: The doctor's office calling ahead
In HTML: The preload in the HTML
In real life: Ready on the counter when you arrive
In HTML: The font, already on its way when the stylesheet needs it
The font, with and without a preloadPop out in the code editor (opens in a new tab)JavaScript
// One variable file on Asha's phone, as recorded (ms after she opened the page)const withoutPreload = { fontAsked: 1172, fontArrived: 2202, caveat: 2221 };const withPreload = { fontAsked: 598, fontArrived: 1586, caveat: 1605 }; console.log(`Without: font asked at ${withoutPreload.fontAsked} ms, Caveat at ${withoutPreload.caveat} ms`);console.log(`With preload: font asked at ${withPreload.fontAsked} ms, Caveat at ${withPreload.caveat} ms`);
  • Line 2 is the variable page without a preload. The font was asked for at 1,172 ms, and the page was all in Caveat at 2,221.
  • Line 3 is the same page with the preload. Chrome asked for the font at 598 ms, at the same moment as the stylesheet. It arrived at 1,586.
  • Lines 5 and 6 print Without: font asked at 1172 ms, Caveat at 2221 ms and With preload: font asked at 598 ms, Caveat at 1605 ms.

Caveat came 616 ms sooner. The preload didn't make the file smaller. It let the font download while the stylesheet was still on its way.

One thing got a little worse. Without the preload, the words first appeared at 1,226 ms. With it, they appeared at 1,289 ms, in all three loads. A preload isn't free. web.dev warns that it comes "at the cost of taking away browser resources from the loading of other resources."

Why the preload needs crossorigin

The CSS Fonts standard fetches every font in CORS mode. CORS is the way one site lets another use its files. Fonts use it even when they come from the page's own site. A plain preload doesn't. The HTML standard files each preload under a key, and the key includes how the file was asked for. So a preload without crossorigin never matches the font the stylesheet wants. Ravi tries it:

The same preload, without crossoriginHTML
<link rel="preload" href="caveat.woff2" as="font" type="font/woff2"><link rel="stylesheet" href="styles.css">

Line 1 is the same preload with crossorigin left out. Chrome asked for the font twice: once for the preload, at 593 ms, and again for the stylesheet, at 1,177 ms. Its console said why, word for word:

Chrome's consoleText
A preload for 'https://shop.test/no-crossorigin/caveat.woff2' is found, but is not used because the request credentials mode does not match. Consider taking a look at crossorigin attribute.

The credentials mode is one part of how a file is asked for: when to send credentials, like cookies. A preload without crossorigin uses one setting, and a font uses another. Resource hints explained the two. It's like a parcel sent under the wrong name: it arrives, but nobody can collect it.

Whether the second request downloads the font again depends on the HTTP cache. On the course's test server, it did. A server proves who it is with a certificate. The test server's was home-made, and Chrome was told to accept it anyway. Chromium's source says: "Do not cache content with cert errors." So nothing was cached, and Asha downloaded the font twice. The page was all in Caveat at 2,215 ms, about the same as with no preload. In Resource hints, Chrome trusted the server, and the second request found the font in the cache. Either way, the preload was wasted.

The variable page on Asha's phone, three ways. No preload: stylesheet from 597 ms to 1,168 ms, font from 1,172 ms to 2,202 ms; all in Caveat at 2,221 ms. Preload with crossorigin: stylesheet from 598 ms to 1,168 ms, font from 598 ms to 1,586 ms; all in Caveat at 1,605 ms. Preload without crossorigin: stylesheet from 594 ms to 1,173 ms, unused preload from 593 ms to 1,582 ms, font from 1,177 ms to 2,197 ms; all in Caveat at 2,215 ms.No preload: all in Caveat at 2,221 msstylesheetfont1,172 msPreload with crossorigin: all in Caveat at 1,605 msstylesheetfont598 msPreload without crossorigin: all in Caveat at 2,215 msstylesheetunused preload593 msfont1,177 ms0 s1 s2 s
With a preload, the font started with the stylesheet, and Caveat came about 600 ms sooner. Without crossorigin, the preload wasn't used: the font started again after the stylesheet.
Playground: preloading the font
What Asha saw
  1. The page at 1,226 ms: the phone's font1,226 ms: the phone's font
  2. The page at 2,221 ms: all in Caveat2,221 ms: all in Caveat
  1. Request 1 for caveat.woff2, from the stylesheet1,172 ms to 2,202 ms, Highest
  2. Font bytes downloaded74,932
  3. Words readable1,226 ms
  4. All in Caveat2,221 ms
  5. Consolenothing
Try it yourself
No preload

The font waits for the stylesheet, then for Chrome to work out which text needs it.

Recorded in Chrome 157, on a phone's screen, with DevTools' Slow 4G and a CPU 4 times slower. Each strip is the load whose last font arrived in the middle, of 3. Times are in ms after Asha opened the page.

Preload the right font

A preload says "this page needs this file now". web.dev lists two catches. A preload "ignores unicode-range declarations", so it fetches its file even if no character needs it. And too many preloads "may divert bandwidth from other critical resources." So preload only the font the first view needs. With the four subset files, that's the Latin one on an English page. With one variable file, it's that file.

Why it matters: a preload lets the font download alongside the stylesheet, which saved 616 ms here. Without crossorigin, it's wasted, and it can cost a whole second download.

Self-hosting or a font service

Ravi's font files live on his own site. That's called self-hosting. Many sites use a font service instead: a site that hosts fonts for anyone to link to. You paste a few lines into your page's head, and it sends the rest. The course built a stand-in on its own computer, so no request left it. fonts.test sends the stylesheet, and files.fonts.test sends the font files. Connections in practice used the same names.

The lines a font service asks you to pasteHTML
<link rel="preconnect" href="https://fonts.test"><link rel="preconnect" href="https://files.fonts.test" crossorigin><link rel="stylesheet" href="https://fonts.test/css2?family=Caveat:wght@400..700&amp;display=swap">
  • Lines 1 and 2 are preconnects: they open connections to the service's two hosts early. The second has crossorigin, because the fonts will be fetched in CORS mode.
  • Line 3 links the service's stylesheet. Its address names the font, the weights from 400 to 700, and display=swap. The &amp; is how HTML writes &.

A font service does the earlier steps for you. Its stylesheet lists one WOFF2 file per alphabet, each with a unicode-range. Google Fonts' docs say the browser "will select from the subsets supported by the font to get what it needs to render the text." The stand-in sends Caveat's four rules, pointing at files.fonts.test.

The cost is more places to go. The course's servers logged every request on a fresh visit to the page with the font service. Ravi's site sent the page and its own stylesheet. fonts.test sent the font stylesheet. files.fonts.test sent one file, caveat-latin.woff2, asked for in cors mode, as the standard says.

Each new host needs its own connection. That's why web.dev says fonts from third-party sites need "separate connection setups." This recording can't show that cost. On Asha's phone, the page was all in Caveat between 2,225 and 2,255 ms with the service, and between 2,205 and 2,233 ms self-hosted. DevTools adds its delay to each request, not to each connection: Lighthouse's docs call it request-level throttling. Connections in practice measured it on a line that slows connections too. The font service's two hosts cost two new connections in a row: 600 ms of handshakes before the font could even be asked for.

There used to be one more reason to use a font service. If a visitor had seen a popular font on another site, it might be in their cache. Not any more. Since version 86, Chrome keeps a separate cache for each site, as The browser's caches showed. Chrome's post on the change named fonts: services that share "fonts and popular scripts" across many sites "may see an increase in their traffic." Firefox 85 split its HTTP cache and its "font cache" the same way.

A font service is like a takeaway: someone else does the cooking, but every order comes from another kitchen. Read each row of the table as a question, then compare the two answers:

A font service and self-hosting, side by side
What you might askWith a font serviceSelf-hosted
Who makes the WOFF2 files?The service does. web.dev says font providers do it "automatically".You do, with a tool like the one in the WOFF2 section.
Who splits the font by alphabet?The service. Its stylesheet lists one file per alphabet, each with a unicode-range.You do, or you take files already split, like Fontsource's.
How many hosts does the browser talk to?Three on the recorded page: Ravi's site, the stylesheet's host and the files' host.One: Ravi's own site.
Is the font already cached from other sites?No. Chrome and Firefox keep each site's cached files apart.No. Each site's files are cached for that site only.
Can the HTML start the font early?The HTML names only the stylesheet. web.dev suggests a preconnect to each host.Yes. The HTML can preload the font file itself.

So self-hosting means doing the service's work yourself. web.dev lists it: WOFF2, subsets, a CDN and HTTP/2. It adds: "Always check the font licenses to confirm they allow subsetting and self-hosting." Caveat's licence, the SIL Open Font License, lets fonts be "used, studied, modified and redistributed freely".

Frameworks can do this for you. Next.js's next/font self-hosts Google fonts. Its docs say they're "served from the same domain as your deployment, meaning no requests are sent to Google by the browser". See Optimizing fonts.

Why it matters: a font service saves you work, but it costs connections and no longer shares a cache. Self-host when you can do its work. Then measure both, including connection setup, as web.dev suggests.

Which fonts should you preload?

For each font file, ask two questions. Does the first view need it? And does your HTML know its address?

Preload it, or not?
  • Caveat's variable Latin file, for the mint tea heading at the top
  • The one font file for the body text the visitor reads first
  • The Cyrillic file, on the English version of the page
  • A bold face used only in the footer, far below the first view
  • Four static weights, "just in case"
  • A font named in a font service's stylesheet
Try it yourself
0 of 6 correct

Sort each font file into preload it or don't.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "WOFF2 changes how the font looks." No. The standard keeps "the exact functionality". In every recorded version, the heading in Caveat measured the same width: 169.7 pixels.
  • "A subset should hold only the letters on this page." No. Text changes, and a letter the subset lacks shows in the next font. Subset by alphabet, unless the text never changes.
  • "With unicode-range, the browser downloads every file listed." No. The English page fetched one file of four.
  • "A variable font is always smaller." No. One static weight, 48,836 bytes, is smaller than the variable file, 74,932.
  • "crossorigin is only for files from other sites." No. Fonts are fetched in CORS mode even from your own site. Without it, Chrome asked for the font twice.
  • "Visitors already have popular fonts in their cache." No. Chrome and Firefox keep a separate cache for each site.

Practice exercises

Answer each one first, then open the hints if you need them.

Exercise 1 · Warm-upWhich range covers it?

What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const latin = [0x0000, 0x00ff];
const cyrillic = [0x0400, 0x045f];
const text = "Чай";

const needs = ([first, last]) => [...text].some((character) => character.codePointAt(0) >= first && character.codePointAt(0) <= last);
console.log(`latin: ${needs(latin)}, cyrillic: ${needs(cyrillic)}`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThree weights, one file

    A page uses Caveat in regular, bold and medium. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const staticFiles = [48836, 51020, 50904]; // regular, bold, medium
    const variable = 74932;
    const total = staticFiles.reduce((sum, bytes) => sum + bytes, 0);
    
    console.log(total > variable ? `variable saves ${total - variable} bytes` : `static saves ${variable - total} bytes`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeBytes for an unused preload

      On the course's test server, with a preload but no crossorigin, how many bytes of font did Asha download in all?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the missing attribute

        Ravi's console warns that his preload is not used. Write the preload line that fixes it.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        <link rel="preload" href="caveat.woff2" as="font" type="font/woff2">
        <link rel="stylesheet" href="styles.css">

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeThis site's own fonts

          Which file format do this site's own fonts use?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeTwo ranges, one character

            The latin and latin-ext rules both include U+0304. In the four-rule stylesheet, the latin rule comes last. Which file does the standard check first for that character?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what the step changed: the bytes, the number of files, or when the font was asked for.

              Smaller, faster fonts quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does this before-and-after code print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const fromTheWebsite = 514284;
                const finished = 74932;
                
                console.log(`${Math.round(fromTheWebsite / finished)} times fewer bytes`);

                Choose an answer to see the explanation.

              2. Question 2 of 7In the recording, why did the two TTF files take so long to reach Asha's phone?

                Choose an answer to see the explanation.

              3. Question 3 of 7A stylesheet has Caveat's four @font-face rules, each with its unicode-range. Which files does Chrome fetch for a page in English?

                Choose an answer to see the explanation.

              4. Question 4 of 7When is one variable font more bytes than static files?

                Choose an answer to see the explanation.

              5. Question 5 of 7What happened in the recording when the preload had no crossorigin attribute?

                Choose an answer to see the explanation.

              6. Question 6 of 7Why doesn't a popular font service's file come from the cache on a visitor's first visit to your site?

                Choose an answer to see the explanation.

              7. Question 7 of 7What does a font preload change about when the font is asked for?

                Choose an answer to see the explanation.

              Key takeaways

              • WOFF2 is the same font, rewritten and squeezed with Brotli. Caveat's was 60% smaller than the TTF.
              • Subset by alphabet. With unicode-range, each visitor downloads only the alphabets on the page.
              • One variable file holds a range of weights. For Caveat, it beats static files from two weights on.
              • Preload the one font the first view needs, with crossorigin. Here it brought Caveat 616 ms sooner.
              • A font service does these steps for you, but adds hosts and connections, and its files are cached per site.

              Remember the one-liner.
              A font comes sooner when it's smaller and asked for earlier: WOFF2, only the alphabets you use, one file for every weight, and a preload for the first view's font.

              Sources

              The facts on this page come from these standards, docs and source code:

              Coming next: Fallback fonts without layout shifts, also in Loading content fast: LCP. Caveat still swaps in after the first paint, and the lines move. Next, make the fallback take the same room as the font.

              CompleteFrontend Clear concepts. Working examples.