Is web font delivery optimized?
The first question that’s worth asking is if we can get away with using UI system fonts in the first place — we just need to make sure to double check that they appear correctly on various platforms. If it’s not the case, chances are high that the web fonts we are serving include glyphs and extra features and weights that aren’t being used. We can ask our type foundry to subset web fonts or if we are using open-source fonts, subset them on our own with Glyphhanger or Fontsquirrel. We can even automate our entire workflow with Peter Müller’s subfont, a command line tool that statically analyses your page in order to generate the most optimal web font subsets, and then inject them into our pages.
WOFF2 support is great, and we can use WOFF as fallback for browsers that don’t support it — or perhaps legacy browsers could be served system fonts. There are many, many, many options for web font loading, and we can choose one of the strategies from Zach Leatherman’s “Comprehensive Guide to Font-Loading Strategies,” (code snippets also available as Web font loading recipes).
Probably the better options to consider today are Critical FOFT with preload
and “The Compromise” method. Both of them use a two-stage render for delivering web fonts in steps — first a small supersubset required to render the page fast and accurately with the web font, and then load the rest of the family async. The difference is that “The Compromise” technique loads polyfill asynchronously only if font load events are not supported, so you don’t need to load the polyfill by default. Need a quick win? Zach Leatherman has a quick 23-min tutorial and case study to get your fonts in order.
In general, it might be a good idea to use the preload
resource hint to preload fonts, but in your markup include the hints after the link to critical CSS and JavaScript. With preload
, there is a puzzle of priorities, so consider injecting rel="preload"
elements into the DOM just before the external blocking scripts. According to Andy Davies, “resources injected using a script are hidden from the browser until the script executes, and we can use this behaviour to delay when the browser discovers the preload
hint.” Otherwise, font loading will cost you in the first render time.
It’s a good idea to be selective and choose files that matter most, e.g. the ones that are critical for rendering or that would help you avoiding visible and disruptive text reflows. In general, Zach advises to preload one or two fonts of each family — it also makes sense to delay some font loading if they are less critical.
It has become quite common to use local()
value (which refers to a local font by name) when defining a font-family
in the @font-face
rule:
/* Warning! Not a good idea! */
@font-face { font-family: Open Sans; src: local('Open Sans Regular'), local('OpenSans-Regular'), url('opensans.woff2') format ('woff2'), url('opensans.woff') format('woff');
}
The idea is reasonable: some popular open-source fonts such as Open Sans are coming pre-installed with some drivers or apps, so if the font is available locally, the browser doesn’t need to download the web font and can display the local font immediately. As Bram Stein noted, “though a local font matches the name of a web font, it most likely isn’t the same font. Many web fonts differ from their “desktop” version. The text might be rendered differently, some characters may fall back to other fonts, OpenType features can be missing entirely, or the line height may be different.”
Also, as typefaces evolve over time, the locally installed version might be very different from the web font, with characters looking very different. So, according to Bram, it’s better to never mix locally installed fonts and web fonts in @font-face
rules. Google Fonts has followed suit by disabling local()
on the CSS results for all users, other than Android requests for Roboto.
Nobody likes waiting for the content to be displayed. With the font-display
CSS descriptor, we can control the font loading behavior and enable content to be readable immediately (with font-display: optional
) or almost immediately (with a timeout of 3s, as long as the font gets successfully downloaded — with font-display: swap
). (Well, it’s a bit more complicated than that.)
However, if you want to minimize the impact of text reflows, we could use the Font Loading API (supported in all modern browsers). Specifically that means for every font, we’d creata a FontFace
object, then try to fetch them all, and only then apply them to the page. This way, we group all repaints by loading all fonts asynchronously, and then switch from fallback fonts to the web font exactly once. Take a look at Zach’s explanation, starting at 32:15, and the code snippet):
/* Load two web fonts using JavaScript */
/* Zach Leatherman: https://noti.st/zachleat/KNaZEg/the-five-whys-of-web-font-loading-performance#sWkN4u4 */ // Remove existing @font-face blocks
// Create two
let font = new FontFace("Noto Serif", /* ... */);
let fontBold = new FontFace("Noto Serif, /* ... */); // Load two fonts
let fonts = await Promise.all([ font.load(), fontBold.load()
]) // Group repaints and render both fonts at the same time!
fonts.forEach(font => documents.fonts.add(font));
To initiate a very early fetch of the fonts with Font Loading API in use, Adrian Bece suggests to add a non-breaking space nbsp;
at the top of the body
, and hide it visually with aria-visibility: hidden
and a .hidden
class:
<body class="no-js"> <!-- ... Website content ... --> <div aria-visibility="hidden" class="hidden" style="font-family: '[web-font-name]'"> <!-- There is a non-breaking space here --> </div> <script> document.getElementsByTagName("body")[0].classList.remove("no-js"); </script>
</body>
This goes along with CSS that has different font families declared for different states of loading, with the change triggered by Font Loading API once the fonts have successfully loaded:
body:not(.wf-merriweather--loaded):not(.no-js) { font-family: [fallback-system-font]; /* Fallback font styles */
} .wf-merriweather--loaded,
.no-js { font-family: "[web-font-name]"; /* Webfont styles */
} /* Accessible hiding */
.hidden { position: absolute; overflow: hidden; clip: rect(0 0 0 0); height: 1px; width: 1px; margin: -1px; padding: 0; border: 0;
}
If you ever wondered why despite all your optimizations, Lighthouse still suggests to eliminate render-blocking resources (fonts), in the same article Adrian Bece provides a few techniques to make Lighthouse happy, along with a Gatsby Omni Font Loader, a performant asynchronous font loading and Flash Of Unstyled Text (FOUT) handling plugin for Gatsby.
Now, many of us might be using a CDN or a third-party host to load web fonts from. In general, it’s always better to self-host all your static assets if you can, so consider using google-webfonts-helper, a hassle-free way to self-host Google Fonts. And if it’s not possible, you can perhaps proxy the Google Font files through the page origin.
It’s worth noting though that Google is doing quite a bit of work out of the box, so a server might need a bit of tweaking to avoid delays (thanks, Barry!)
This is quite important especially as since Chrome v86 (released October 2020), cross-site resources like fonts can’t be shared on the same CDN anymore — due to the partitioned browser cache. This behavior was a default in Safari for years.
But if it’s not possible at all, there is a way to get to the fastest possible Google Fonts with Harry Roberts’ snippet:
<!-- By Harry Roberts.
https://csswizardry.com/2020/05/the-fastest-google-fonts/ - 1. Preemptively warm up the fonts’ origin.
- 2. Initiate a high-priority, asynchronous fetch for the CSS file. Works in
- most modern browsers.
- 3. Initiate a low-priority, asynchronous fetch that gets applied to the page
- only after it’s arrived. Works in all browsers with JavaScript enabled.
- 4. In the unlikely event that a visitor has intentionally disabled
- JavaScript, fall back to the original method. The good news is that,
- although this is a render-blocking request, it can still make use of the
- preconnect which makes it marginally faster than the default.
--> <!-- [1] -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <!-- [2] -->
<link rel="preload" as="style" href="$CSS&display=swap" /> <!-- [3] -->
<link rel="stylesheet" href="$CSS&display=swap" media="print" onload="this.media='all'" /> <!-- [4] -->
<noscript> <link rel="stylesheet" href="$CSS&display=swap" />
</noscript>
Harry’s strategy is to pre-emptively warm up the fonts’ origin first. Then we initiate a high-priority, asynchronous fetch for the CSS file. Afterwards, we initiate a low-priority, asynchronous fetch that gets applied to the page only after it’s arrived (with a print stylesheet trick). Finally, if JavaScript isn’t supported, we fall back to the original method.
Ah, talking about Google Fonts: you can shave up to 90% of the size of Google Fonts requests by declaring only characters you need with &text
. Plus, the support for font-display was added recently to Google Fonts as well, so we can use it out of the box.
A quick word of caution though. If you use font-display: optional
, it might be suboptimal to also use preload
as it will trigger that web font request early (causing network congestion if you have other critical path resources that need to be fetched). Use preconnect
for faster cross-origin font requests, but be cautious with preload
as preloading fonts from a different origin wlll incur network contention. All of these techniques are covered in Zach’s Web font loading recipes.
On the other hand, it might be a good idea to opt out of web fonts (or at least second stage render) if the user has enabled Reduce Motion in accessibility preferences or has opted in for Data Saver Mode (see Save-Data
header), or when the user has a slow connectivity (via Network Information API).
We can also use the prefers-reduced-data
CSS media query to not define font declarations if the user has opted into data-saving mode (there are other use-cases, too). The media query would basically expose if the Save-Data
request header from the Client Hint HTTP extension is on/off to allow for usage with CSS. Currently supported only in Chrome and Edge behind a flag.
Metrics? To measure the web font loading performance, consider the All Text Visible metric (the moment when all fonts have loaded and all content is displayed in web fonts), Time to Real Italics as well as Web Font Reflow Count after first render. Obviously, the lower both metrics are, the better the performance is.
What about variable fonts, you might ask? It’s important to notice that variable fonts might require a significant performance consideration. They give us a much broader design space for typographic choices, but it comes at the cost of a single serial request opposed to a number of individual file requests.
While variable fonts drastically reduce the overall combined file size of font files, that single request might be slow, blocking the rendering of all content on a page. So subsetting and splitting the font into character sets still matter. On the good side though, with a variable font in place, we’ll get exactly one reflow by default, so no JavaScript will be required to group repaints.
Now, what would make a bulletproof web font loading strategy then? Subset fonts and prepare them for the 2-stage-render, declare them with a font-display
descriptor, use Font Loading API to group repaints and store fonts in a persistent service worker’s cache. On the first visit, inject the preloading of scripts just before the blocking external scripts. You could fall back to Bram Stein’s Font Face Observer if necessary. And if you’re interested in measuring the performance of font loading, Andreas Marschke explores performance tracking with Font API and UserTiming API.
Finally, don’t forget to include unicode-range
to break down a large font into smaller language-specific fonts, and use Monica Dinculescu’s font-style-matcher to minimize a jarring shift in layout, due to sizing discrepancies between the fallback and the web fonts.
Alternatively, to emulate a web font for a fallback font, we can use @font-face descriptors to override font metrics (demo, enabled in Chrome 87). (Note that adjustments are complicated with complicated font stacks though.)
Does the future look bright? With progressive font enrichment, eventually we might be able to “download only the required part of the font on any given page, and for subsequent requests for that font to dynamically ‘patch’ the original download with additional sets of glyphs as required on successive page views”, as Jason Pamental explains it. Incremental Transfer Demo is already available, and it’s work in progress.