Chapter 05 — Web Typography

Loading Web Fonts Without Wrecking Performance

Chapter 04 ended on a fallback stack and a promise: the generic family at the end of --font-serif: 'Fraunces', Georgia, serif is doing more work than it looks like. This chapter is about that work.

A web font is not part of the page. It’s a separate file on a separate HTTP request, and the browser doesn’t even know it needs that file until it has parsed the CSS and found the @font-face rule. So there’s a gap. The browser knows it wants Fraunces, the font file hasn’t arrived yet, and it has to decide what to put on screen in the meantime. Everything in this chapter is about controlling what happens during that gap.


FOIT and FOUT

There are two ways the browser can handle the wait, and both have names.

FOIT is the Flash of Invisible Text. The browser hides the text until the font loads, then reveals it all at once in the right typeface. While the font is in flight, that block of content is just empty space. It looks clean when the font arrives in 200 milliseconds. It looks broken when the font takes four seconds, and it looks catastrophic when the font never arrives at all, because then the page has no readable text on it. The reader is staring at a blank where a paragraph should be.

FOUT is the Flash of Unstyled Text. The browser shows the text immediately in a fallback font, then swaps to the web font once it loads. The content is readable from the first paint. The cost is the swap: when Fraunces replaces Georgia, the letters are different widths, lines re-wrap, and the layout shifts. It’s a visible jump. Uncomfortable, but the reader was never locked out of the content.

Between these two, FOUT is almost always the better trade. Invisible text is a worse failure than shifted text, because shifted text still communicates. The job is to pick FOUT on purpose and then minimize the shift, rather than letting the browser default decide for you.


font-display

font-display is the CSS property that makes that choice. It lives inside the @font-face declaration, and it tells the browser how to behave during the gap.

swap gives you FOUT. Show the fallback right away, swap to the web font whenever it arrives, however long that takes. This is the value to reach for most of the time. The text is readable instantly and the real font always wins eventually.

optional is swap with a deadline. The browser shows the fallback, gives the web font a very short window to load, and if it misses that window the font is skipped entirely for this page load. No swap, no layout shift. This is the value to use when a font is decorative rather than load-bearing, or when Cumulative Layout Shift is a hard performance budget you cannot blow. The reader on a fast connection gets the real font; the reader on a slow one gets a stable page in the fallback and doesn’t get jerked around.

block gives you FOIT: a block period of roughly three seconds where the text is invisible, then a swap. Don’t use this without a specific reason. Hiding text for three seconds to avoid a fallback flash is rarely the right call.

fallback sits in the middle. A very short block period (around 100 milliseconds), then the fallback, then a swap if the font arrives within about three seconds. It trims the worst of the FOUT flash for fast connections without locking content away for long. Useful, but swap is the simpler default and the one to start from.


A correct @font-face declaration

When you self-host a font, you write the @font-face rule yourself. Here is a complete, correct one:

css~/tutorials/
@font-face {
  font-family: 'Fraunces';
  src: url('./fonts/fraunces-variable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

A few things worth pointing out. The format('woff2') hint lets the browser skip a file it can’t use, though woff2 has near-universal support now so you rarely need anything else. The font-weight: 100 900 is the giveaway that this is a variable font: instead of a single weight, the declaration covers the whole range from 100 to 900 in one file. And font-display: swap is the line that turns FOIT into FOUT.


Variable fonts cut the request count

A variable font holds a range of weights, and sometimes widths or optical sizes, inside a single file. For the pairing from Chapter 04, a serif for headings and a sans for body, the static approach means a separate file for every weight you use: regular body, bold headings. That’s three or four requests for two typefaces.

The variable versions of Fraunces and DM Sans each cover their full weight range in one file. Two typefaces, two requests. Every weight you might want is already inside. The file is larger than a single static weight, but smaller than the stack of static weights it replaces, and crucially it’s fewer round trips. Fewer requests is the thing your loading performance cares about most.


Preloading

font-display controls what happens during the gap. Preloading shrinks the gap itself.

Normally the browser only discovers a font after it has downloaded and parsed the CSS, found the @font-face rule, and worked out which characters on the page need it. A preload hint jumps that queue. You put a <link> in the document <head> that tells the browser to start fetching the font file immediately, in parallel with the CSS:

html~/tutorials/
<link
  rel="preload"
  href="/fonts/fraunces-variable.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

The crossorigin attribute is not optional, and this trips people up. Fonts are always fetched in a special anonymous mode, so a preload without crossorigin gets treated as a different request than the real font load. The browser downloads the file twice: once for the preload that goes unused, once for the actual @font-face. You add crossorigin even when the font is served from your own domain. Leave it off and the preload quietly does the opposite of its job.

Preload sparingly. Reserve it for the one or two font files that render text above the fold. Preload everything and you flood the connection at the worst possible moment, when the browser is also fetching CSS and the rest of the critical path.


Self-hosting versus Google Fonts

Google Fonts serves font files from Google’s CDN. It’s fast and the setup is a single <link> tag. The cost is that every page load now depends on a third party, you have less say over font-display, and the request to a Google domain carries privacy and GDPR considerations that have caused real legal trouble in the EU.

Self-hosting means the font files live on your own server. One fewer third-party dependency, full control over the @font-face rule, and the site works the same offline or behind a strict content security policy. For any typeface that’s on Google Fonts, the @fontsource npm packages make this painless: npm install @fontsource-variable/fraunces, import it, and the font is bundled into your build with no external request at all. That’s how this site runs. Fraunces and DM Sans both come in through @fontsource, so loading them costs zero requests to anyone else’s domain.

One self-hosting gotcha: the server has to send .woff2 files with the font/woff2 MIME type. Most web servers do this out of the box. If you’re configuring a custom server or a CDN and fonts mysteriously fail to apply, check the response headers first.


The order of operations

Putting it together, the loading setup for a project goes like this. Self-host the fonts, either through @fontsource or by downloading the files into the project. Write the @font-face declarations with font-display: swap so a slow font never hides your content. Add a <link rel="preload"> with crossorigin for the one or two font files that paint above the fold. Then declare the families as CSS custom properties, exactly the --font-serif and --font-sans pattern from Chapter 04, each with its fallback stack intact. The fallback is what carries the page during the swap, which is why it was never just boilerplate.


What comes next

That closes the design half of this series. You can build a type scale, set a comfortable measure and leading, pair two typefaces with intent, and load them without a flash of invisible text. The text on the page looks the way you decided it should.

The other half is what that text is. A heading set at --text-2xl in Fraunces is still, structurally, just an element, and the browser, search engines, and screen readers all need to know whether it’s an h2 or a div that happens to look big. Chapter 06 starts the semantics track: not how text looks, but what the HTML elements mean.