Webfont loading with FOUT

(2017-01-28)

For manpages.debian.org, I looked at loading webfonts. I considered the following scenarios:

# local? cached? Network Expected Observed
1 Yes / / perfect render perfect render
2 No Yes / perfect render perfect render
3 No No Fast FOUT FOIT
4 No No Slow FOUT some FOUT, some FOIT

Scenario #1 and #2 are easy: the font is available, so if we inline the CSS into the HTML page, the browser should be able to render the page perfectly on the first try. Unfortunately, the more common scenarios are #3 and #4, since many people reach manpages.debian.org through a link to an individual manpage.

The default browser behavior, if we just specify a webfont using @font-face in our stylesheet, is the Flash Of Invisible Text (FOIT), i.e. the page loads, but text remains hidden until fonts are loaded. On a good 3G connection, this means users will have to wait 500ms to see the page content, which is far too long for my taste. The user experience becomes especially jarring when the font doesn’t actually load — users will just see a spinner and leave the site frustrated.

In comparison, when using the Flash Of Unstyled Text (FOUT), loading time is 250ms, i.e. cut in half! Sure, you have a page reflow after the fonts have actually loaded, but at least users will immediately see the content.

In an ideal world

In an ideal world, I could just specify font-display: swap in my @font-face definition, but the css-font-display spec is unofficial and not available in any browser yet.

Toolbox

To achieve FOUT when necessary and perfect rendering when possible, we make use of the following tools:

CSS font loading API
The font loading API allows us to request a font load before the DOM is even created, i.e. before the browser would normally start processing font loads. Since we can specify a callback to be run when the font is loaded, we can apply the style as soon as possible — if the font was cached or is installed locally, this means before the DOM is first created, resulting in a perfect render.
This API is available in Firefox, Chrome, Safari, Opera, but notably not in IE or Edge.
single round-trip asynchronous font loading
For the remaining browsers, we’ll need to load the fonts and only apply them after they have been loaded. The best way to do this is to create a stylesheet which contains the inlined font files as base64 data and the corresponding styles to enable them. Once the browser loaded the file, it will apply the font, which at that point is guaranteed to be present.
In order to load that stylesheet without blocking the page load, we’ll use Preloading.
Native <link rel="preload"> support is available only in Chrome and Opera, but there are polyfills for the remaining browsers.
Note that a downside of this technique is that we don’t distinguish between WOFF2 and WOFF fonts, we always just serve WOFF. This maximizes compatibility, but means that WOFF2-capable browsers will have to download more bytes than they had to if we offered WOFF2.

Combination

The following flow chart illustrates how to react to different situations:

Putting it all together

Example fonts stylesheet: (base64 data removed for readability)

@font-face {
  font-family: 'Inconsolata';
  src: local('Inconsolata'),
       url("data:application/x-font-woff;charset=utf-8;base64,[…]") format("woff");
}

@font-face { font-family: 'Roboto'; font-style: normal; font-weight: 400; src: local('Roboto'), local('Roboto Regular'), local('Roboto-Regular'), url("data:application/x-font-woff;charset=utf-8;base64,[…]") format("woff"); }

body { font-family: 'Roboto', sans-serif; }

pre, code { font-family: 'Inconsolata', monospace; }

Example document:

<head>
<style type="text/css">
/* Defined, but not referenced */

@font-face { font-family: 'Inconsolata'; src: local('Inconsolata'), url(/Inconsolata.woff2) format('woff2'), url(/Inconsolata.woff) format('woff'); }

@font-face { font-family: 'Roboto'; font-style: normal; font-weight: 400; src: local('Roboto'), local('Roboto Regular'), local('Roboto-Regular'), url(/Roboto-Regular.woff2) format('woff2'), url(/Roboto-Regular.woff) format('woff'); }
</style> <script type="text/javascript"> if (!!document['fonts']) { /* font loading API supported / var r = "body { font-family: 'Roboto', sans-serif; }"; var i = "pre, code { font-family: 'Inconsolata', monospace; }"; var l = function(m) { if (!document.body) { / cached, before DOM is built / document.write("<style>"+m+"</style>"); } else { / uncached, after DOM is built */ document.body.innerHTML+="<style>"+m+"</style>"; } }; new FontFace('Roboto', "local('Roboto'), " + "local('Roboto Regular'), " + "local('Roboto-Regular'), " + "url(/Roboto-Regular.woff2) format('woff2'), " + "url(/Roboto-Regular.woff) format('woff')") .load().then(function() { l(r); }); new FontFace('Inconsolata', "local('Inconsolata'), " + "url(/Inconsolata.woff2) format('woff2'), " + "url(/Inconsolata.woff) format('woff')") .load().then(function() { l(i); }); } else { var l = document.createElement('link'); l.rel = 'preload'; l.href = '/fonts-woff.css'; l.as = 'style'; l.onload = function() { this.rel = 'stylesheet'; }; document.head.appendChild(l); } </script> <noscript> <style type="text/css"> body { font-family: 'Roboto', sans-serif; } pre, code { font-family: 'Inconsolata', monospace; } </style> </noscript> </head> <body>

[…content…]

<script type="text/javascript"> /* inlined loadCSS.js and cssrelpreload.js from https://github.com/filamentgroup/loadCSS/tree/master/src */ (function(a){"use strict";var b=function(b,c,d){var e=a.document;var f=e.createElement("link");var g;if(c)g=c;else{var h=(e.body||e.getElementsByTagName("head")[0]).childNodes;g=h[h.length-1];}var i=e.styleSheets;f.rel="stylesheet";f.href=b;f.media="only x";function j(a){if(e.body)return a();setTimeout(function(){j(a);});}j(function(){g.parentNode.insertBefore(f,(c?g:g.nextSibling));});var k=function(a){var b=f.href;var c=i.length;while(c--)if(i[c].href===b)return a();setTimeout(function(){k(a);});};function l(){if(f.addEventListener)f.removeEventListener("load",l);f.media=d||"all";}if(f.addEventListener)f.addEventListener("load",l);f.onloadcssdefined=k;k(l);return f;};if(typeof exports!=="undefined")exports.loadCSS=b;else a.loadCSS=b;}(typeof global!=="undefined"?global:this)); (function(a){if(!a.loadCSS)return;var b=loadCSS.relpreload={};b.support=function(){try{return a.document.createElement("link").relList.supports("preload");}catch(b){return false;}};b.poly=function(){var b=a.document.getElementsByTagName("link");for(var c=0;c<b.length;c++){var d=b[c];if(d.rel==="preload"&&d.getAttribute("as")==="style"){a.loadCSS(d.href,d);d.rel=null;}}};if(!b.support()){b.poly();var c=a.setInterval(b.poly,300);if(a.addEventListener)a.addEventListener("load",function(){a.clearInterval(c);});if(a.attachEvent)a.attachEvent("onload",function(){a.clearInterval(c);});}}(this)); </script> </body>