The importance of `@font-face` source order when used with preload

The other day I decided to run a quick WebPageTest (WPT) run over the latest version of the White House website launched for President Biden’s term in office (it’s amazing what I do for fun in lockdown huh!). The WPT run returned something curious:

The waterfall from WebPageTest using Chrome on a Moto G4 on a 3G Fast connection.

The above waterfall was captured using Chrome on a real Moto G4 with a 3G Fast connection. That’s a lot of font files being downloaded (red requests), 14 files in total.

So let’s investigate what is happening here. First thing to notice is that there are 6 fonts that are loading right after the HTML has downloaded and parsed (request 2-7). This is a sure sign that these fonts are being preloaded. What usually happens is the DOM is constructed, then the CSS downloaded to create the CSSOM. These are both combined to form the render tree. At this point, fonts referenced in the @font-face rule will be discovered and requested by the browser (assuming they are needed to render the specified text on the page).

Note: This blog post isn’t a criticism of the excellent work the team at the U.S. Digital Service does. It’s just an interesting observation I’ve never encountered or considered before. Being a Civil Servant myself, I understand how hard everyone works to deliver user friendly services (especially under current conditions!). Keep being awesome!

Note 2: Turns out USDS didn’t build this website!

Note 3: As of 29th Jan the font loading issue has now been fixed by the White House developer team and can be seen on the live site.

Font load priority

There’s an important point to consider here. The order in which you list your font sources in the @font-face rule is very important. According to the CSS Fonts Module Level 3 specification:

It is required for the @font-face rule to be valid. Its value is a prioritized, comma-separated list of external references or locally-installed font face names. When a font is needed the user agent iterates over the set of references listed, using the first one it can successfully activate. Fonts containing invalid data or local font faces that are not found are ignored and the user agent loads the next font in the list.

Or in other words, the browser will start at the top of the list looking for a font format it supports. The first one it encounters is loaded, and if successful the sources listed later are ignored. Or in code it looks like this:

@font-face {
  font-family: bodytext;
  src: url(our-test-font.woff2) format("woff2"), /* WOFF2 tried first */
       url(our-test-font.woff) format("woff"), /* WOFF tried second */
       url(our-test-font.ttf) format("opentype"); /* TTF tried if others not supported. */
}

There’s a large crossover between browsers that support WOFF and those that support WOFF2. Or to phrase it another way, if a browser supports WOFF2, it also supports WOFF. This is important, as if you are listing your WOFF fonts first in the @font-face rule, the browser is going to download and use these instead of the WOFF2 versions (which offer much better compression because they use Brotli). So for example if you do this:

@font-face {
  font-family: bodytext;
  src: url(our-test-font.woff) format("woff"), /* WOFF tried first */
       url(our-test-font.woff2) format("woff2"), /* If WOFF is successful, WOFF2 is ignored */
       url(our-test-font.ttf) format("opentype"); /* TTF tried if other not supported. */
}

Assuming the WOFF font doesn’t 404, the WOFF2 font will never be requested by the browser!

The Waterfall

The above information can now be used to explain what is happening with the waterfall we see above.

Preload (requests 2-7)

Examining the code from the White House website we can see the following in the <head>:

<link rel="preload" href="...fonts/Decimal-Book.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="...fonts/Decimal-Semibold.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="...fonts/Decimal-Medium_Web.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="...fonts/MercuryTextG2-Roman-Pro_Web.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="...fonts/MercuryTextG2-Semibold-Pro_Web.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preload" href="...fonts/MercurySSm-Medium-Pro_Web.woff2" as="font" type="font/woff2" crossorigin="anonymous">

These preload links tell the browser to immediately make the requests for the WOFF2 versions of the fonts as soon as the browser parses the <head>, which it happily does as it trusts that these fonts will be used later in the pages lifecycle. Once these requests have been made and are sent over the network the browser is going to get a response back (assuming the fonts are available and don’t 404). Even if the browser were to cancel the requests, it will still receive bytes back from the server for the fonts. They just wouldn’t be used. There’s an example waterfall of this happening here and a blog post all about it here.

CSS (request 9)

At request 9 we can see the CSS file being downloaded and parsed by the browser. Also notice how only 2 of the preloaded fonts have been fully downloaded. This is a critical part of the page load with limited bandwidth available. Under the hood the browser still needs fonts to be able to render text on screen. A preload will prime the browser cache, but it won’t add a font to the FontFaceSet. For that you either need to use the CSS Font Loading API, or the traditional @font-face rule.

Within this CSS file on request 9 we see the @font-face rules (I’ve only referenced 1 as an example, but there are many):

@font-face {
    font-family: MercuryTextG2-Semibold-Pro_Web;
    src: url(.../fonts/MercuryTextG2-Semibold-Pro_Web.woff) format("woff"),
    	url(.../fonts/MercuryTextG2-Semibold-Pro_Web.woff2) format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: block
}

Notice the ordering of the src fonts in this @font-face rule. The WOFF version of the font is listed first, then WOFF2 listed second.

The result

The browser is now making 8 more font requests to WOFF font files (requests 10-17). To the browser it sees these as completely separate files. It doesn’t know that the glyphs contained within them are actually identical to the ones from the preloaded fonts! The browser is now doubling up on the number of fonts requested because it has stopped at the WOFF font src in the @font-face rule. It has no knowledge about the WOFF2 version directly below because it has found the first one it supports, then stopped (as defined in the specifications).

This is probably easier to explain on a waterfall chart:

In the waterfall we can see the preloaded WOFF2 fonts being requested, then later after CSS parsing another 8 font requests to the WOFF versions of the fonts.

The above waterfall is from Chrome on a Moto G4 with a 3G Fast connection, but I have also managed to replicate the same result on iOS Safari, Microsoft Edge (Chromium), and Firefox (which now supports preload).

Cloudflare Workers

Here’s where I get the chance to play with Cloudflare Workers again. For information on how this is done I recommend reading Andy Davies’ excellent ‘Exploring Site Speed Optimisations With WebPageTest and Cloudflare Workers’ blog post, or you can watch him talk about it at London Web Performance here. If you’d like to see a fully commented sample of my worker code I’ve created a gist here.

Modifying the preloads

First let’s investigate what happens if we ‘fix’ the preload links and point them to the WOFF fonts referenced in the @font-face rules.

We now see the waterfall has changed and the WOFF fonts are being preloaded, removing the need for then to be requested by the `@font-face` rules.

In the resulting waterfall we now see the WOFF fonts preloaded in requests 2-7. This in turn reduces the number of fonts requested by the @font-face rules after the CSS has been parsed.

Modifying the CSS

Now let’s do the reverse, let’s update the CSS @font-face rules and swap the order so the WOFF2 fonts are first. This will then match the WOFF2 fonts listed in the preload links in the head.

Here we see the WOFF2 fonts preloaded and very few font requests after CSS parsing.

In this waterfall the preload is untouched, but I’ve simply made the WOFF2 fonts come first in the src order like so:

@font-face {
    font-family: MercuryTextG2-Semibold-Pro_Web;
    src: url(.../fonts/MercuryTextG2-Semibold-Pro_Web.woff2) format("woff2"),
    	url(.../fonts/MercuryTextG2-Semibold-Pro_Web.woff) format("woff");
    font-weight: 400;
    font-style: normal;
    font-display: block
}

It’s amazing what such a small change can do to a waterfall.

Effect on performance

But it’s not a cleaner looking waterfall that’s important, it’s the performance a user sees after the change. So the first thing to note is the impact on the number of bytes downloaded:

Number of bytes downloaded from the different versions. We see the original with the most, then the preloaded WOFF fonts, and the least, preloaded WOFF2 with fixed `@font-face` rules.
Version Total Size (bytes)
Original 2,133,401
Fixed preload 1,904,496
Fixed @font-face 1,696,008

Reordering the @font-face rule alone has reduced the number of bytes downloaded by 440KB. That’s a pretty decent saving by simply swapping a few 2’s in the font filename and format().

But where you really see the difference is in the visual progress graph:

The visual progress really shows the difference fixing the font face can make on perceived performance
Version Visually Complete (ms)
Original 8,668
Fixed preload 5,681
Fixed @font-face 5,311

We’ve shaved off almost 3.4 seconds from the visual complete time on a Moto G4 / 3G Fast connection, a 39% reduction. That’s a huge improvement in performance for such a minor change. Your users will really notice that change. If you are interested in the full test results then here’s the WebPageTest comparison view with all three tests.

Is this a problem in the real world?

So a good question to ask is now: is this a one-off occurrence, or does it happen quite frequently? Well, with December data from the HTTP Archive and the help of Barry Pollard we are able to answer this question:

  Mobile (abs) Desktop (abs) Mobile (%) Desktop (%)
Download same WOFF / WOFF2 font (one fails) 155,541 132,044 2.17 2.19
Download same WOFF / WOFF2 font (both return 200) 69,805 59,387 0.97 0.99

We see 155,541 (2.17%) sites try to download both WOFF and WOFF2 fonts with the same filename. What most likely happens in these instances is the WOFF2 file fails, so the browser moves onto the WOFF fallback. This still isn’t great for web performance since an extra request / response is made and adds time to the page load and blocks text rendering (I’m over-simplifying here I know). But what’s worse is that we see 69,805 (0.97%) sites successfully double download both WOFF and WOFF2 versions of the exact same font! That’s a lot of unnecessary bytes being downloaded which simply won’t be used. If you are interested in the full dataset for this query you can view it here.

Spotting the preload issue

So how would you know if this is an issue for your site? Well the easiest thing to do is to look in your browser console. Both Firefox and Chrome will give you a set of warnings if you are preloading assets that aren’t actually used in the page:

If you preload assets that aren't used in the page load the browser will most likely tell you in the console. Warnings from Firefox about the unused fonts.

What if the first src fails

So let’s now look at what happens with a waterfall when the first source in the @font-face fails. This isn’t related to the White House website but the HTTP Archive data shows that it is happening for approximately 86,000 (1.2%) of sites in the HTTP Archive data. There’s a caveat with this data. The only thing we know is that the browser tries to download both types of font files (WOFF & WOFF2). We also know that one of these fonts doesn’t return a 200 status code (hence the fallback font then loads). But we don’t have an exact figure on if one of the font requests was triggered by a preload, then falling back to @font-face. We also don’t have the data on what source order the fonts are loaded in if using @font-face. So all in all, it’s complicated! But what we do know is that the browser tried to load fonts of both types, and for some reason one of them failed (4xx status code).

What does this look like on a waterfall and what’s the performance impact?

The waterfall & performance impact

Below you can see the annotated waterfall for a site that requests the WOFF2 font first but fails. This test was again run on a real Moto G4 / 3G Fast connection:

In the waterfall we see lots happening where by both sets of WOFF and WOFF2 fonts are loaded over the lifecycle of the page.

There’s a fair amount going on in this waterfall so let’s step through it:

  • On request 37 we see the TCP negotiation happen for the font domain. Once completed the font request goes out for the WOFF2 font. This whole process takes 2 seconds to complete. It takes ~865 ms to fail. That includes a full round trip to the server and back.
  • On request 41 it takes a full 1.7 seconds to fail. Notice how once failed the browser almost immediately requests the fallback WOFF font (request 46).
  • Notice how this fail / request pattern for all 4 fonts are aligned. Requests 37 & 43, 39 & 45, 40 & 44, and 41 & 46 are all font pairings (WOFF2 vs WOFF).

So in other words the browser must receive a font failure before it can request the fallback. This lack of primary font source is adding 2 seconds to the font load in this test case. What’s worse in the test case above is that the browser ends up downloading the larger of the two font file types. Since WOFF files can be 20-30% larger than WOFF2 files. Ultimately the user gets a double whammy in terms of poor performance: added time to make a successful font request, and more data to download.

So how can you check for this? Well, again I’d look to the browser console. If you notice 404 errors for fonts, I’d put those errors pretty high up your prioritisation list to fix!

Summary

So in summary, when you are preloading fonts make sure that what is preloaded matches with the src defined in your @font-face rules. Remember the first src the browser finds wins. If these don’t match then your users may be downloading two sets of exactly the same fonts. Make sure you check your browser console for preload warnings and font 404 errors. As both of these issues can seriously impact page performance and adding to the data requirements for the site.


Post changelog:

  • 23/01/21: Initial post published. Thanks to Barry Pollard for the HTTP Archive data and Andy Davies for the Cloudflare Worker debugging.
  • 24/01/21: Added more detail about the performance impact of fonts failing to load, thanks to Barry Pollard for the scenario. Added links for more information about Cloudflare workers. Added info about USDS having no involvement in the White House website. Added a link to the gist with a sample of the cloudflare worker code I used.
  • 29/01/21: Added update to say the issue with fonts has now been fixed.
Loading

Webmentions