-
-
[转帖]Improving perceived performance with the CSS `font-display` property
-
发表于: 2020-2-27 11:02 2287
-
[转帖]Improving perceived performance with the CSS `font-display` property
Original link: https://nooshu.github.io/blog/2020/02/23/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:
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 (know 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)#
So it turns out that @font-face
and the Font Loading API aren’t as intertwined as I first thought. When I initially wrote the post I believed that Edge didn’t support @font-face
at all, but it turns out that it does. But what it doesn’t support is the display
property being used via the API loading method, seen here. The reason I find this unusual is the specification says:
The FontFace interface represents a single usable font face. CSS
@font-face
rules implicitly defineFontFace
objects, or they can be constructed manually from a url or binary data.
So the fact that Edge will be implicitly creating a FontFace
object from its @font-face
rules, means that it must be using a different method other than the API to support the font-display
property. Either that, or the data behind ‘mdn-api_fontface_display’ on caniuse is incorrect. Thanks to Zach Leatherman for helping clear up this mystery.
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.
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:
- A WebPageTest run starts, the browser negotiates the server connection and requests the HTML.
- HTML is downloaded and parsed, other page assets like CSS and JS are requested.
- At this point WPT injects the JavaScript and it executes. Our new font is defined and loaded using the Font Loading API.
- The CSS is downloaded and parsed, it also contains an
@font-face
rule with the same basic info (font name, font URL etc). - 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. - 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 can work. 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:
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:
- What happens if you have lots of fonts? You need to replicate them all in the injection script.
- 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
A much 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:
- A WebPageTest run starts, the browser negotiates the server connection and requests the HTML.
- HTML is downloaded and parsed, other page assets like CSS and JS are requested.
- At this point WPT injects the JavaScript and it may execute. If it does we create a copy of the
` element that is loading our
@font-face` CSS and wait for it to load. - CSS loads and is parsed. Browser creates
FontFace
objects for the@font-face
rules in the CSS (CSS-connected). - CSS load event fires and our custom
FontFace
objects are added to theFontFaceSet
last, so is therefore prioritised over the CSS-connected font settings (e.g. thefont-display
property). - 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.
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:
- Standard page with a single web font loaded via the the CSS
@font-face
rule. This is our control (font-display: auto
) link. - Standard page + the same web font added a second time using the Font Loading API, with
font-display: swap
set link. - Standard page + the same web font added a second time using the Font Loading API, with
font-display: fallback
set link. - 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.
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.
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/2020: Initial post published.
- 25/02/2020: Posted an update about
font-display
support on Edge (Chromium). Thanks Zach Leatherman! - 26/02/2020: Posted another update around
font-display
support in Edge. It doesn’t supportdisplay
in the Font Loading API.
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课