Web Fonts Done Right

How to properly configure web fonts, optimize for fast flicker-free rendering, and avoid tofu bloopers

by Joe Honton

When using web fonts we must carefully avoid its hidden pitfalls. To do this designers, frontend developers, DevOps, and authors each have a role to play in creating a great end-user experience.

  • Designers need to establish font stacks that can realistically be delivered to users over restricted bandwidths.
  • Frontend developers need to pay attention to CSS rules that can speed things up.
  • DevOps needs to make sure font files are fetched at the right time, and cached aggressively once downloaded.
  • Content authors need to let everyone else on the team know about special glyphs and foreign language character sets that they use.

Here are some practical tips and strategies for getting fast accurate typography using real-world network bandwidths.


The @font-face rule

Let's begin with a typical example. The designer chooses a typeface for their website articles with a careful eye for readability. The designer then declares the typeface, size, leading, white space, and color using CSS like this:

article {
font-family: 'Source Serif Pro', serif;
font-size: 12pt;
line-height: 1.4;
margin: 1rem;
color: hsl(0,0%,15%);
background-color: hsl(0,0%,85%);
}

In order to implement this, the frontend developer specifies @font-face rules like this:

@font-face {
font-family: 'Source Serif Pro';
font-weight: 400;
font-style: normal;
font-display: block;
src: local('SourceSerifPro-Regular'),
url('/fonts/source-serif-pro-400-latin.woff2')
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Source Serif Pro';
font-weight: 400;
font-style: italic;
font-display: swap;
src: local('SourceSerifPro-It'),
url('/fonts/source-serif-pro-400-italic-latin.woff2')
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Source Serif Pro';
font-weight: 600;
font-style: normal;
font-display: swap;
src: local('SourceSerifPro-Semibold'),
url('/fonts/source-serif-pro-600-latin.woff2')
format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}

At first glance, this looks like an excessive ruleset just for a simple CSS declaration. So let's examine each property to understand what's possible.

For all three rules, the font-family is the same. It matches the name specified in the CSS declaration.

It's the font-weight and font-style properties that are the telling difference.

  • The first rule is used for plain text, it specifies a regular font weight (400) and a normal font style.
  • The second rule specifies an italic font style. It will be used by the browser for HTML text marked with the <i> or <em> tags, or with thefont-style: italic CSS property.
  • The third rule specifies a heavier font weight (600). It will be used by the browser for HTML text marked with the <b> or <strong> tags, or with thefont-weight: bold CSS property.

When the italic and bold typefaces don't need to be precise, and download times need to be optimized, the second and third rules can be dropped. In that case, the browser will attempt to render its own approximation of italic and bold by simply distorting the outline of the true font. In some cases this may be good enough.

But when the typeface can't be suitably rendered that way, the frontend developer can still optimize the website for fast downloads by using the font-display property. A value of swap instructs the browser to prerender the text the best it can, then rerender it correctly when the font file becomes available. This makes the text visible to the user sooner, but may cause a small "flash of unstyled text" (FOUT). In the example, the italic and bold rules use this approach.

By way of contrast, a value of block instructs the browser to temporarily hold off rendering the text until the font file becomes available. When this happens within a reasonable amount of time it's rendered in its precise form without any FOUT. But if the font file download is delayed too long the browser is supposed to temporarily do the best it can, rerendering if and when the actual font becomes available. In the example, the first rule, for regular plain text, uses this approach.

Two other font-display values are possible (but not shown in the example). First, a value of fallback instructs the browser to briefly render the text with a fallback font, waiting just a short time to obtain the actual font. If it can be obtained quickly enough (within 3 seconds), the text is rerendered with the actual font. But if it can not be obtained within that time-frame, it must simply give up trying and stick with the fallback.

Finally, a value of optional instructs the browser to give up immediately if the font isn't available when first needed, and permanently render the text with a fallback of its choice.

The next part of each @font-face rule — the src property — specifies where to look for the font file. One or more locations may be provided. If there is any chance that it might be preinstalled on the user's device, use the local() keyword to tell the browser to look for it by name.

The more typical scenario though, is to use the url() keyword. This syntax will trigger a network request to fetch the specified file (but only if and when it's needed).

It is perfectly acceptable to specify a URL that points to a third party provider like https://fonts.gstatic.com (Google Fonts). But this approach can sometimes lead to unexpected delays, causing your website to compete with every other website using that service. For more predictable behavior, that is under your control, consider hosting these files on your own servers.

The format() keyword specifies the encoding and compression of the font file. All modern browsers support the new Web Open Font Format, so there's very little reason to use older TTF or EOT formats anymore. And be sure to specify WOFF2 which optimizes glyph compression using the new brotli compression scheme.

The last part of each @font-face rule — the unicode-range property — specifies which Unicode code points have actual glyph definitions within the font file. This is a natural language optimization that some font foundries provide for typefaces that cover more than just the ASCII character set.

For example, if your font file has Greek characters it might only have glyphs defined for code points U+0370-03FF. Or for example, Cyrillic might only have glyphs defined for code points U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116.

By providing separate font-face rules with unicode-range this way, you can leave it up to the browser to decide which font files to fetch based on the actual text on each web page.

One final note about @font-face rules. Because font files are only fetched by the browser when needed, it is quite common to have one set of @font-face rules that cover every possible need for an entire website. There is no need to hand-craft different rules for each page.


Preloading fonts

Web font files need to be fetched before being used. The browser does this automatically, as needed, using its internal algorithm for matching the glyphs on a page and the CSS rules for those glyphs. This algorithm is not exposed to the developer, so there is no direct way of tweaking it.

But oftentimes this on-demand fetching mechanism isn't quick enough to prevent the annoying flash of unstyled text. One strategy to deal with this is HTML's preload protocol. Here's how the frontend developer can instruct the browser to preload the three fonts of the previous example:

<head>    
<link href='/fonts/source-serif-pro-400-latin.woff2'
rel=preload as=font type=font/woff2 crossorigin />
<link href='/fonts/source-serif-pro-400-italic-latin.woff2'
rel=preload as=font type=font/woff2 crossorigin />
<link href='/fonts/source-serif-pro-600-latin.woff2'
rel=preload as=font type=font/woff2 crossorigin />
</head>

In this code snippet, the rel=preload attribute instructs the browser to prioritize the download of these files over all others. So these three will be requested before any scripts, stylesheets or images. This is the most important secret for preventing the flash of unstyled text.

HTML's link tag can be used for many other things, but the as=font attribute is to be used only for @font-face rules. It instructs the browser to follow the content security policy for fonts and to send an accept request header that servers will correctly interpret.

The type=font/woff2 attribute is the corresponding content-type response header that the browser expects to see on the file it obtains. Together with as=font these two prevent accidental and malicious misuse of the preload instruction.

Finally, the crossorigin attribute is required when preloading fonts, regardless of whether it comes from the website's host itself or a third party host.

Two important points about this strategy. First, don't overdo it — not every @font-face rule needs this priority treatment. Consider using it only for titling and places where large blocks of text are a significant part of the web page experience. And remember that the browser will still load all others in due course. When italicized and heavy weight font variants only serve to emphasize words and short phrases, a short delay in their final rendering will often be unnoticeable.

Second, double check to make sure that any preloaded font files are actually used. It's counterproductive to have users wait for a font file that is not going to be used. When putting this strategy in place be sure to examine the Chrome DevTools Console for warnings similar to this:

The resource /fonts/source-serif-pro-400-latin.woff2 was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it wasn't preloaded for nothing.

Glyph subsets

Web fonts can be used for special purposes other than general text content and titling. For example, the well known Font Awesome project contains a collection of popular icons that are packaged as a font file. Typically, most web pages that use Font Awesome only need a small subset of the approximately 1600 icons that each font file contains, so downloading a large file just to get a couple of icons is inefficient.

This is where font subsetting is useful. The idea is to create a font file that only contains the glyphs that are needed. To accomplish this, a tool like pyftsubset is used. It opens an existing font file, copies the SVG outlines of the chosen glyphs, and repackages the result in a brand new font file.

This same technique can be used with any appropriately licensed font file. For example, the splash page for ЯΈAĐ WЯΊTΈ TΘΘLŠ chose to render its title font in graffiti-style lettering to match its crazy/cool skateboarder theme. But instead of using an alternate typographic face, the website stuck with the designer's choice (Source Sans Pro) and simply swapped in non-latin glyphs for R, E, D, I, O and S. The final font subset contains just 20 glyphs packed into a 5Kb file.

Yes, this type of optimization is a lot of extra work. Still, it can be worth it when trying to deliver a resource-heavy website over constrained networks such as mobile cell-towers.


Caching

Once a font file has been obtained, it should be cached on the client's device. Since font files change rarely, if ever, they should be treated as a static resource. DevOps should configure the server to cache these files for a maximum amount of time. One year is a good rule to follow. Configure the server to set the response header like this:

cache-control: max-age=31536000

If the font foundry issues an updated version within that period, use any of the traditional cache-busting techniques that are available. But of course, only if the new file contains new glyphs that your content authors need.


Server push

DevOps may be tempted to help things along by using speculative push. This new protocol is available with HTTP/2. It works by sending targeted resources to the browser before the browser requests them.

Speculative push was a much hyped solution to the problem of resource optimization. The reality is that it fares no better than the link rel=preload solution outlined above. Benchmark tests prove the point. Read all about it here.


Replacement glyphs

Content authors may be blissfully unaware of all the details just covered, and may inadvertently run into trouble. Remember, every font file contains a limited set of glyphs. So when the author uses a glyph that is not in the chosen font family the browser tries its best to find an alternative. This often leads to unsightly replacements.

For example, if the author is writing about Bosnian politician Šefik Džaferović, then the rules we've defined above won't work. Instead, the browser will obligingly use Š, ž, and ć from some other serif font that it has access to. That replacement might be good enough on the author's computer, but may be completely different on a random reader's computer.

If the designer has chosen a "pro" font family that has an extended set of glyphs, the solution is simple: specify an additional @font-face rule with a unicode-range that covers U+0160, U+017E and U+0107. Thus, extending the original example we might add the "latin-ext" font file to our ruleset like this:

@font-face {
font-family: 'Source Serif Pro';
font-weight: 400;
font-style: normal;
font-display: block;
src: local('SourceSerifPro-Regular'),
url('/fonts/source-serif-pro-400-latin-ext.woff2')
format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020,
U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}

Tofu and fallback fonts

Sometimes an author may use esoteric glyphs that render reasonably well on their own computer, but that completely fail on the reader's computer. This may happen for example when the author is writing on a computer with the newest hardware and operating system, but the reader is on a different hardware or older software that knows nothing about those glyphs.

We sometimes mistakenly think of Unicode as being the miraculous solution to these types of problems, but remember that Unicode is a living standard. New versions with additional code points are added on a regular basis.

When this unfortunate circumstance occurs, the reader may discover "tofu" characters sprinkled into the text. The solution to this is to refine the CSS font stack, adding a fallback font that is guaranteed to have the widest possible set of glyphs.

Google developed the Noto font just for this purpose. As of this writing, it spans 30 distinct writing scripts. It has glyphs in serif, sans-serif and monospaced styles.

Because it is so comprehensive, it's too big to use as a web font without splitting it up. Use of the unicode-range rule should be considered mandatory when using Google Noto as a web font.


Language-specific fonts

Unicode has steadily increased support for more of the world's ancient and modern languages. Each year new code points are added to the spec. But because of the time and expense involved, only a few font foundries have created glyph sets for these new code points.

When attempting to use any of these newer code points for authoring web pages, it is imperative that the author and frontend developer work closely to make sure that the reader can see what the author has written.

For example, if the author is writing about Egyptian archeology and wants to use hieroglyphs within the body of the text, the CSS from our original working example might be adjusted to look like this:

article {
font-family: 'Source Sans Pro',
'Noto Sans Egyptian Hieroglyphs',
sans-serif;
font-size: 12pt;
line-height: 1.4;
margin: 1rem;
}

And an extra @font-face rule would be added like this:

@font-face {
font-family: 'Noto Sans Egyptian Hieroglyphs';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/ea/notosansegyptianhieroglyphs
/v1/NotoSansEgyptianHieroglyphs-Regular.woff2)
format('woff2');
unicode-range: U+13000-1342E;
}

With this in place, the article's principal text will be rendered in Source Sans Pro, and the hieroglyphs will appear correctly, without tofu, using Noto Sans. See this example.


Emojis

There is no font file, with cross platform support, that contains all of the Unicode-defined emojis. This limitation is largely because fonts have been a black-and-white affair up until now. Four different competing schemes have been developed to make use of color in fonts (SBIX, COLR, SVG-in-OpenType, and CBDT/CBLC). None of these enjoys universal support across all operating systems.

In the mean time, we are left with:

  • Google Noto Color Emoji for Android and Linux
  • Apple Color Emoji for iOS, macOS, watchOS and tvOs
  • Microsoft Segoe Color Emoji for Windows 10
  • Twitter Emoji for Everyone for SVGinOT

Unfortunately the rendering of each emoji is open to artistic interpretation. As a result, the author and reader will often see something slightly different. Sometimes the difference is so great that it is open to misinterpretation. For example 'FACE WITH ROLLING EYES' (U+1F644) looks exasperated with Google, confused with Apple, and amused with Samsung.

As of this writing, a cross-platform web font solution to this problem remains elusive. Of course this doesn't mean we can't use emojis. But it does mean that designers can't expect to have their designs faithfully rendered on every platform readers use. Because of this, a web font solution to the problem is simply not worth pursuing at this time.


Follow these six golden rules to ensure a great end-user experience with web fonts:

  1. Define @font-face rules for normal, italic and bold variants for each font family used.
  2. Use the font-display property to specify an appropriate FOUT strategy.
  3. Add HTML <link rel=preload> statements to prioritize the delivery of critical fonts.
  4. Create font subsets for company-specific lettering needs.
  5. Cache aggressively.
  6. Watch for tofu and respond by specifying fallback fonts when necessary.

Web fonts can be a part of every website's design. Avoiding the hidden gotchas requires diligence, but getting it right is worth the trouble.

Web Fonts Done Right — How to properly configure web fonts, optimize for fast flicker-free rendering, and avoid tofu bloopers

🔎