Improving perceived performance with the CSS `font-display` property

Typography on the web has come a long way since the days of Scalable Inman Flash Replacement (sIFR) and later cufón. It was tough times for typographers and frontend developers on the web back then. I used to dread seeing a PSD file with some exotic font used in the design, as I knew hours of cross-browser adjustments lay ahead. Thankfully, on the modern web we have the @font-face CSS at-rule which allows us to specify a custom font when displaying our text. It hasn’t always been plain sailing, but today in 2020 many of the issues have been ironed out and browser support for WOFF2 fonts is looking really good. So what’s the catch? Web Performance.

As the proverb says: “With great power comes great responsibility”. Give people the ability to use custom fonts easily on a website and surly that won’t be abused? Unfortunately it does, and it has an impact on web performance, and ultimately the usability of a website.

Fonts and Web Performance

If you use a non-system font on a website, the browser is going to have to fetch that font file from the server. Not a problem you may think, but if a site uses multiple custom fonts and font weights, or if you are having to support multiple languages, sometimes the size of the font files can make up a large percentage of the total page download size.

So if the font is downloading, what does the browser do with the content? Well, typically what browsers used to do was render an invisible placeholder font, then replace it with the actual font once it had downloaded. This method is known as the Flash of Invisible Text (FOIT). That’s not ideal, but at least a usr can see something right? But what happens if a user is on a slow connection and the font takes 10’s of seconds to download? Or what if the font never loads at all? The user is left looking at a website that looks like this:

The page is loading and the page outline if visible, but a user can't see the text because the font is still travelling over the network.

In the example above a user is having to wait 5 seconds before any text is rendered to the viewport. That’s a long time to be waiting considering Google research found that 53% of mobile website visitors will leave a website if it doesn’t load within three seconds. So what can we do to improve this perceived performance issue? font-display to the rescue!

Font display property

The font-display property is defined in the CSS Fonts Module Level 4 specification. What it does is allow a developer to choose what happens during the font rendering phase of a page load. Just to be 100% clear, it has no effect on the actual loading of the font itself over the network, just what happens to typography on the page during this loading phase. Font display has five possible values, let’s discuss these briefly:

Block

This is the default for many browsers. This setting will immediately draw the invisible placeholder text (known as the block period), invisibility period persists indefinitely until the web font is loaded. The browser has an infinite period to swap the invisible font for the web font once it has loaded.

Fallback

According to the specification:

[Fallback] gives the font face an extremely small block period (100ms or less is recommended in most cases) and a short swap period (3s is recommended in most cases).

So, on page load invisible text could be shown for up to 100ms (known as the block period). Beyond 100ms, the next font in the CSS font stack is displayed (e.g. Arial, Helvetica Neue, Helvetica, sans-serif). The browser is then given 3 seconds to swap the fallback font for the web font. Beyond 3 seconds the fallback font will be used for the rest of the page life and the web font will never be displayed (even if it successfully downloads).

The 3 second cutoff is a superb setting if you know you have users on a slow connection. Imagine a page loading on a slow device. The fallback font is rendered, so a user starts to read the content. They may be 5 - 10 seconds into reading, then suddenly the web font (that they know nothing about) suddenly loads. If the font metrics of the web font and fallback font are different, this can results in a large page jump which is quite disruptive.

From my observations (as seen later in the article), the practical application of the specification is true to its word. Invisible placeholder text is displayed for 100ms, then the fallback font is painted.

Swap

The specification says:

[Swap] gives the font face an extremely small block period (100ms or less is recommended in most cases) and an infinite swap period.

Or to put it another way, on page load the browser could show the invisible text and then wait for up to 100ms before displaying the fallback font. Beyond this point the browser has as much time as needed to swap the fallback font for the web font once loaded.

But the practical implementation in browsers is different. For swap there is no block period. The fallback font is displayed as soon as the page is painted. So even though the specification makes fallback and swap sounds very similar on page load, they are actually different in terms of perceived performance.

Optional

The optional value is the one I find most interesting, yet it is the least used according to the 2019 Web Almanac. The specification is the longest of the five values, but in simple terms: it gives the browser the option to abort the font download, or load it at the lowest priority. This setting could be very useful if you are on a slow cellular network. Should the browser decide that the connection is too slow for the font to load, it will abort the request completely and simply display the fallback font for the rest of the page’s lifetime.

I’m intrigued as to why this option isn’t used more widely, given its obvious benefit to both the usability of a website and its improved data footprint for slow connections. But if I were to hazard a guess, I’d say it’s because it is very strict in terms of repainting. If the fallback font is displayed, the web font will never be shown, even if loaded quickly. So this results in users on a fast device & connection where the fallback has been painted, the web font has loaded but it isn’t rendered. Only if the user navigates through to another page, is the web font then rendered. So I’d imagine a number of designers being quite annoyed if their chosen web font isn’t visible on initial page load!

Auto

A very simple one to understand. auto will use whatever setting the user-agent already has defined. For many browser this will be block.

Browser support

Browser support for font-display is actually looking great, with around 82% of users globally using a browser that supports it. A couple of the surprising outliers for me are:

Samsung Internet

Samsung Internet (SI) is a browser that has been gaining some market share on Android devices. It currently doesn’t support font-display. I suspect this is because SI v10.x is based on Chrome 71 (which didn’t support font-display). There are rumours on the web that v11.x will be based on Chrome 75, which does support font-display. So you never know, support may be just around the corner.

Edge (Chromium)

Final edit of this section. It turns out that the data in caniuse was incorrect. Edge does support font-display in both @font-face and via the API, as this test proves. I opened an issue and it was fixed in under 10 hours. The power of open source strikes again! If you check out the FontFace API: display now, you will see the correct values. I’m leaving this section here to remind readers you can’t always trust your sources! Thanks to Zach Leatherman for helping clear up this mystery, and vinyldarkscratch, Fyrd for the fixes.

Testing the perceived performance

So how can we test the effect these different font-display settings have on a websites perceived page performance? One way would be to simply update your @font-face rule, deploy it, then run it though WebPageTest. That works but there is another way that may simplify this workflow.

And that’s by leveraging WebPageTest’s ‘Inject Script’ functionality (under the ‘Advanced’ tab), and using the CSS Font Loading API.

Inject script allows you to modify the font loading properties on a given page.

Here we use a very similar method to what Andy Davies demonstrates in his Improving Perceived Performance With the Link Rel=preconnect HTTP Header blog post.

You can find some simplified code below which can be used to do this, or on a Gist found here:

(function(){
  // this will trigger a font load
  var customFont1 = new FontFace('custom font name', 'url([FONT_URL_HERE])', {
    display: 'block', // display setting to test here
    weight: '700' // font-weight
    // other font properties here
  });

  // IMPORTANT: add the font to the document
  document.fonts.add(customFont1);

  // monitor the font load (optional)
  customFont1.loaded.then((fontFace) => {
    // log some info in the console
    console.info('Font status:', customFont1.status); // optional
    // log some info on the WPT waterfall chart
    performance.mark('wpt.customFont1Loaded'); // optional
  }, (fontFace) => {
    // if there's an error, tell us about it
    console.error('Font status:', customFont1.status); // optional
  });

  // repeat above for multiple fonts

})();

Modify the above code and paste it into the ‘Inject Script’ textarea. The above code will be injected into the test page before the test executes and will load and define a new font (using the same font-family name) with your updated settings.

The key to get this solution to work is to ensure the manually added font is added after the CSS @font-face fonts are registered. As the priority of fonts with the same font-family name is: the last one added wins. This mirrors CSS, e.g. if you have two selectors with the same specificity, the one which comes last ‘overwrites’ the first.

Lets look at hypothetical test timeline:

  1. A WebPageTest run starts, the browser negotiates the server connection and requests the HTML.
  2. HTML is downloaded and parsed, other page assets like CSS and JS are requested.
  3. At this point WPT injects the JavaScript and it executes. Our new font is defined and loaded using the Font Loading API.
  4. The CSS is downloaded and parsed, it also contains an @font-face rule with the same basic info (font name, font URL etc).
  5. The FontFace object and @font-face are CSS-connected. The parsing of the CSS adds the font to the bottom of the list (it is last), so it is prioritised over our manually added font.
  6. Page loads as normal, no changes are observed from the injected script.

Point 3 is the part of this process that is unpredictable. There are no timing guarantees that the injected script will run after CSS download / parsing. It may be different depending on the browser and it runs as soon as the document exists. So if the above code doesn’t work for you in your particular site setup, what else can you try?

Solutions

So there are two solutions that I have found to rectify this issue:

Block the font CSS

This one is less than ideal, but it works. If you happen to have your @font-face rules defined in their own separate CSS file, then you can simply use WebPageTest’s ‘block’ functionality to block the font CSS:

Using the WPT UI you can block assets from loading. Here we block the font CSS from loading.

What happens in this situation is our injection script creates all the relevant FontFace objects, and they don’t get deprioritised by the @font-face rules in the CSS. There are a couple of major issues with this method:

  1. What happens if you have lots of fonts? You need to replicate them all in the injection script.
  2. What about if your font CSS is concatenated along with your other CSS? You essentially need to block your whole pages CSS.

So yes, this method could work, but it’s far from perfect.

Detect the CSS load

Disclaimer: The following method does work, but web page load is complex and nondeterministic. So what works for one run (as seen in the tests below), suddenly will stop working in other tests using the exact same code. The CSS blocking method is much more predictable.

A better method I have found is to hook into the fact that you can detect when a CSS file has loaded with a little bit of JavaScript. It is possible to modify the injection script to do this. Here’s a Gist with the modified code:

(function(){
  // create our custom link tag for the stylesheet
  var url = "https://www.example.com/static/app.css"; // IMPORTANT: this is the CSS file that contains your @font-face rules
  var head = document.getElementsByTagName('head')[0];
  var link = document.createElement('link');
  link.type = "text/css";
  link.rel = "stylesheet"
  link.href = url;

  // append the stylesheet to the head
  head.appendChild(link);

  // wait for the CSS file to load before modifying the font setup
  link.onload = function () {
    // define our font face and modify the properties (will trigger a load)
    var customFont1 = new FontFace('nta', 'url([FONT_URL_HERE])', {
      display: 'swap', // display setting to test here
      weight: '700' // font-weight
      // other font properties here
    });

    // IMPORTANT: add the modified font to the FontFaceSet
    document.fonts.add(customFont1);

    // monitor the font load (optional)
    customFont1.loaded.then((fontFace) => {
      // log some info in the console
      console.info('Font status:', customFont1.status); // optional
      // log some info on the WPT waterfall chart
      performance.mark('wpt.customFont1Loaded'); // optional
    }, (fontFace) => {
      // if there's an error, tell us about it
      console.error('Font status:', customFont1.status); // optional
    });

    // repeat above for multiple fonts
  }
})();

With this script we are hooking into the load event of the CSS that contains our @font-face rules (this is important). So here’s what is happening in the browser:

  1. A WebPageTest run starts, the browser negotiates the server connection and requests the HTML.
  2. HTML is downloaded and parsed, other page assets like CSS and JS are requested.
  3. At this point WPT injects the JavaScript and it may execute. If it does we create a copy of the <link> element that is loading our @font-face CSS and wait for it to load.
  4. CSS loads and is parsed. Browser creates FontFace objects for the @font-face rules in the CSS (CSS-connected).
  5. CSS load event fires and our custom FontFace objects are added to the FontFaceSet last, so is therefore prioritised over the CSS-connected font settings (e.g. the font-display property).
  6. Page loads with our modified font settings.

And there you have it. A quick way to test out different font-display settings on a live website and observe the results in WebPageTest.

Debugging a WebPageTest run

This is a really useful tip from Andy Davies that I’ve slightly modified for these examples. If you want to see what the console output is for a browser in a test. Paste the following code into the ‘Inject Script’ textarea:

// hijack the console.log method, allowing you to log all console massages
window._consoleMessages = [];
console.log = function(m){_consoleMessages.push(m)};

// specifically used for this font example to 'copy' the `FontFace` object
function flatten(obj) {
    var result = {};
    for(var key in obj) {
        result[key] = obj[key];
    }
    return result;
}

Then the following into the ‘Custom Metrics’ box under the ‘Custom’:

[consoleMessages]
for(var font of document.fonts){
  // push results into the hijacked console
  // could easily return a simple array though if required
  console.log(flatten(font));
}

return JSON.stringify(_consoleMessages, null, 2);
The custom metrics tab allows you to capture custom metrics from a test.

This will give you an output that looks similar to this:

The custom metrics can be found inside the test run, and they give a list of outputs captured during the test.

Very useful if you are looking for specific information about a browser in a test run.

Let’s see it in action

I’ve created a super simple set of pages to demonstrate this technique. There’s also a little information on what is printed to the console in browsers that support the API. If you are familiar with DevTools, simply open them up and you will see the information in the console. I’ve created 4 test pages:

  1. Standard page with a single web font loaded via the the CSS @font-face rule. This is our control (font-display: auto) link.
  2. Standard page + the same web font added a second time using the Font Loading API, with font-display: swap set link.
  3. Standard page + the same web font added a second time using the Font Loading API, with font-display: fallback set link.
  4. Standard page + the same web font added a second time using the Font Loading API, with font-display: optional set link.

Pages 2, 3 and 4 are a proof-of-concept. They each include the above script after the fonts.css file. So on these pages the font will be manually added to the FontFaceSet along with a new font-display property. Will it show any changes in WebPageTest? NOTE: The web font is only applied to the header (h1) on the pages, so that’s what you should be concentrating on in the following tests.

A filmstrip from the 4 pages showing the difference the script can have on font loading.

You can take a closer look at the comparison of the 4 pages here, but here are the results as I see them:

  • Optional: The header rendered at the start with invisible placeholder text. ~100ms later fallback text is rendered. The web font never renders.
  • Fallback: The header rendered at the start with invisible placeholder text. ~100ms later fallback text is rendered. The font loads within the 3 second cut-off, so is swapped out.
  • Swap: The header immediately loads with the fallback font visible (e.g. the 0s block period in action). The fallback is swapped for the web font once loaded.
  • Auto: As expected, this is set to block. Invisible placeholder text is rendered, no text is shown until the web font is loaded.

All of the above results are the expected outcome for each font-display value, and the fact that we see a difference shows that a font added programmatically via the CSS Font Loading API, can change the page. So let’s take it one step further. Let’s get WebPageTest to inject the script for us.

In these tests we use the control page in all instances, then get WebPageTest to inject the script with each of the different font-display values. A full comparison of the results from these tests can be seen here.

Results from using WebPageTest to inject the scripts and change the font settings.

It looks remarkably similar to the image above doesn’t it? In fact, on closer inspection we see exactly the same results as listed above for our manually injected script pages. So we can actually use WebPageTest to change a pages font-display property (and really any property that isn’t read only). I’m sure others may be able to come up with other uses for this technique too. Give it a try for yourself: check out this Gist with a link to the test page and the script for you to play with.

Conclusion

And there you have it, a way to change the font-display settings of a page when using WebPageTest. No need to manually update the code to see what effect the property has on a pages perceived performance. I’d love to hear your ideas and / or improvements that can be made to this method, so please let me know via Twitter.


Post changelog:

  • 23/02/20: Initial post published.
  • 25/02/20: Posted an update about font-display support on Edge (Chromium). Thanks Zach Leatherman!
  • 26/02/20: Posted another update around font-display support in Edge. It doesn’t support display in the Font Loading API.
  • 27/02/20: Final update for font-display in Edge. It is fully supported in both @font-face and the API. Caniuse data was incorrect!
  • 28/02/20: Added information about debugging a test run via the ‘Inject Script’ and ‘Custom Metrics’ functionality.
Loading

Webmentions