More than you ever wanted to know about font loading on the web

When I started thinking about writing a post about web font loading my intention was to propose relatively sophisticated ideas that I've been playing with for a while. However, as I was trying to use them in real-world websites I realized that deployment of the more advanced techniques is de-facto impossible without the creation of new web standards.

With that the TL;dr of this post is: Use font-display: optional. However, I and many others really like our custom fonts. See the rest of the post for how we can get our cake and eat it, too–with a tool that automatically makes fallback fonts behave like their respective custom font counterpart.

Web fonts and Core Web Vitals #

2 metrics in Google's Core Web Vitals are directly impacted by font loading:

  1. Largest Contentful Paint (LCP) measures (among other things) when text renders. With text rendering blocked behind the web font download, LCP may be delayed.
  2. Cumulative Layout Shift (CLS) measures the document shifting around as the browser loads additional data. A browser switching from fallback font to a custom font leads to layout shift if fallback and custom font flow differently.

The following video is showing the layout-shift created by font-loading.

CLS and invisible text #

Font-based layout shift doesn't require the fallback font ever having displayed. If the page renders without the custom font having loaded but the fallback text remains invisible (this happens with font-display: auto, the default) then the space that is reserved for the invisible text depends on the space that would be taken up by the fallback text. Once the custom font comes in and the text becomes visible, there is a layout shift as the space taken up for text changes.

Stop worrying and use font-display: optional #

Why font-display: optional is currently the only good option #

With font-display: optional the browser only renders the custom font if it is available extremely quickly. In most scenarios that requires it being cached locally.

This leads to the best possible LCP: Your text always renders quickly, independent of network speed.

And it leads to the best possible CLS: Your custom font loading never causes layout shift because it is only used when it is available for the first text paint.

When not to use font-display: optional #

The one reason that makes usage of font-display: optional impossible is if there is no viable fallback font: You need the custom font to load to make sense of the content. Generally that is the case for icon fonts. You probably shouldn't use these in the first place as they are bad for accessibility: You need to see the icons to comprehend them, and you cannot assign them alt-text.

Using preload with font-display: optional #

Browsers will only download a font for a web page when CSS evaluation completes and it is determined that it is actually used on the page. That is much later than e.g. when image downloads are initiated which are done by the so-called pre-parser that does a quick scan of the HTML document as soon as it is available to the browser (and without blocking on synchronous scripts, stylesheets, etc.).

The common work-around is to use a link-preload element like this which explicitly instructs the browser to start the font download as soon as it discovers the element.


The question is: Should you use these together with font-display: optional? The conventional wisdom is: No. The reason being that with font-display: optional either your font is already in cache in which case this doesn't do anything, or the font download is likely not fast enough to make the short deadline and the browser would render the fallback font anyway. In the latter case you give bandwidth at the most urgent time to the font which will never render and that bandwidth could be used to download other critical resources instead.

When disk caching isn't enough #

However, with font-display: optional even users who have the font cached to disk might see the fallback font for the first page view of a new session because the custom font hasn't been loaded into the memory cache (Note, that while you might be developing your website on a laptop or phone with a very fast SSD, many of your users will have degraded storage devices with cache performance worse than a fast network). At least Chrome has a heuristic that if you preload the font, then it will hold painting for a few 100 of milliseconds and give the client a chance to fetch the font from disk or through a very fast internet connection. Whether you have a "budget" to spend those extra 100s milliseconds depends on your overall load performance.

Fonts and CDNs #

One of the big changes in the web ecosystem over the last few years is that browsers no longer cache resources across top level sites. That means if your site and my site both load the exact same Roboto from Google Fonts, the browser will download it twice as opposed to only once like they used to do. This is very sad. It is, however, also the right call in the short-term from a privacy & security perspective. In the long-term, maybe we can define web standards that eliminate the privacy & security threats from cross-origin caching for heavily shared resources like fonts.

So, what are the consequences of this change in browser caching behavior? The main change is that font CDNs like Google Fonts and Adobe TypeKit now strictly make your site slower. They used to help with cross-site caching, but that benefit is gone. Instead they add expensive cross-origin requests (and their DNS lookups, TLS negotiations, etc.) into the critical path of loading your website.

With that it is clear that we should self-host all fonts on our primary domain for maximum performance. With fonts this can sometimes be problematic for licensing reasons, etc. but there is a good middle ground: Instead of self-hosting the fonts, self-host the loading code. For all common Font CDNs (even TypeKit with some digging, they default to JS based loading) this is simply a CSS file. Just download that CSS file. It won't bite 😄. Or, if your font provider likes to sometimes change it, just fetch the CSS file once during your build process. Then inline the CSS file into your HTML and you completely eliminate the expensive cross-origin request from your critical path. While this approach still downloads the fonts themselves from the CDNs this doesn't hurt when you are using our friend font-display: optional.

What if I really don't want to use font-display: optional #

So, I have a solution for you. It works remarkably well. This is based on an idea/tool by Monica Dinculescu that she published in 2016. It allows tweaking your fallback font such that it uses approximately as much space as the custom font.

This is an awesome idea as it avoids the layout shift issues associated with loading the custom font: With the fallback already taking up the right amount of space, the custom font just swaps back into the same space when it loads.

Tool: Perfect-ish font fallbacks #

My contribution over Monica's idea is that I made a tool that automatically matches the fallback font to the custom font–because computers are good at that stuff. Try it out here.

The tool allows you to select every Google Font from a select menu. If you aren't using a Google Fonts, you can remix this Glitch for a custom solution.

Samples #

The fallback-to-custom-font matching works really well in most cases. Here the left font is the custom font and on the right side is Arial:

Comparison of rendering of Montserrat and Arial

However, the whole thing is just an approximation. It definitely happens that things do not match 100%. (The screenshot shows the same text/font as before, but uses a different viewport width). The solution works most of the time. It isn't perfect but better than always having a major layout jump.

Same fonts as in previous image but showing that Arial flows one line shorter

Finally, your mileage may vary with more extreme fonts. For very narrow fonts the fallback font may become unreadable. Having said that, for fonts that are commonly used this is not a problem.

Text with negative letter spacing that has the characters flow into each other

Deploying fallback corrections to the website #

The output of the tool is a bit of CSS like

<style id="font-correction">
html {
letter-spacing: 0.0605em;
line-height: 1.3;

Unfortunately, this is where things get complicated. What you need to do is have your page use this CSS but only (and that is very important) until the moment that it renders the custom font. You can try out this demo for a working implementation. This is based on Bram Stein's excellent FontFaceObserver and the magic is basically here:

new FontFaceObserver("Montserrat").load().then(function () {
var s = document.getElementById("font-correction");

What this does is: When our custom font loads (Montserrat), then remove the style element we defined above such that the correction for the fallback font is eliminated.

So far, so simple.

Where things get really complicated is when trying to deploy this with more than one web font (or variant of the same logical font). Then you need to manage N corrections (letter-spacing and line-height), apply them only to the correct text that is styled with that font, and remove them individually as the respective font file loads.

I've decided for myself that it isn't worth the effort as it would be too fragile to ever work in practice.

A web standard solution #

Update 2021-05-23 #

With the @font-face properties size-adjust, ascent-override, and descent-override a web standard solution is coming for the font-matching problem space. For now browser support is minimal but implementations are landing in both Chrome and Firefox already. You can check out a demo (requires Chrome Canary or Firefox Nightly) here.

My original proposal #

While handling the font-loading state machine is complicated in JavaScript (besides the ridiculousness of using JavaScript to control font-loading) and expressing the font-changes in CSS relative to the base styling is even more complicated, there is a party that could handle this quite easily: The browser. What if I could say: "When Arial is a fallback font for X, then use the following letterSpacing, etc.".

In CSS that would look something like this:

@font-face {
font-family: "My font";
/* WARNING: Just a proposal */
/* Configure fallback font for "My Font" */
fallback-font: "Arial"; /* Would also avoid specifying redundant fallbacks in font-family rules */
fallback-font-letter-spacing: 0.0605em;
fallback-font-word-spacing: 0.001em;
fallback-font-line-height: 1.3;

And the best part of an approach like this: browsers could just ship better defaults for common fonts. There aren't that many fonts in use, and at <=64bit of information needed per font, browsers could easily ship fallback configuration for the ~1000 most common web font names.

Summary #

Using font-display: optional together with self-hosting the CSS for your web fonts gets you in really good shape with respect to LCP and CLS. There are more sophisticated techniques but it is probably worth waiting for web standards that make it easier to use them.