Improving Font Performance: CDNs and Service Workers

Serve through a Specialized CDN

Serving content through a CDN has always been a key to improve performance. The edge servers are in diverse geographical locations and they will route users to the closest server to their location.

Serving fonts through a CDN is no different. Sites like Adobe Fonts (formally known as Typekit) and Google Fonts provide a faster experience downloading the fonts to your devices.

Combine the CDN service with preconnect or dns-fetch resource hints for an even faster experience.

Cache fonts using a service worker

Most of the time when we hear about Service Workers we hear about them in the context of Progressive Web Applications (PWAs) but they can also be used to improve your site’s performance by caching assets in the browser.

Fonts will be the largest assets we cache with the service worker so we want to keep a few locally hosted fonts around and keep them for a while so we don’t have to download them too often.

Using Workbox.js we can simplify the process of creating a font cache or even multiple caches for local and external fonts.

const fontHandler = workbox.strategies.cacheFirst({
  cacheName: "fonts-cache",
  plugins: [
    new workbox.expiration.Plugin({
      maxAgeSeconds: 30 * 24 * 60 * 60,
      maxEntries: 10

When caching external fonts we want to do three things:

  • Cache them for a long time (30 days in this case)
  • Make sure we cache opaque responses
  • Allow the browser to purge the cache if the origin’s quota is exceeded

We do the last step because opaque responses are usually padded by browsers to prevent user information from leaking across domains and we want to make sure that browsers will handle full caches for the origin (your site) before the browser decides what to delete instead of asking you.

const extFontHandler = workbox.strategies.staleWhileRevalidate({
  cacheName: "external-fonts",
  plugins: [
    new workbox.expiration.Plugin({
      maxAgeSeconds: 30 * 24 * 60 * 60
    new workbox.cacheableResponse.Plugin({
      statuses: [0, 200],
      // Automatically cleanup if quota is exceeded.
      purgeOnQuotaError: true

The second part is to register the routes that will use our handlers. The first route defines a route for external fonts from Google fonts (from googleapis or gstatic) and uses the extFontHandler handler.

// Third party fonts
  args => {
    return extFontHandler.handle(args);

The second route matches local fonts by extension for TTF, OTF, WOFF, and WOFF2. This route uses the fontHandler handler.

// Fonts
workbox.routing.registerRoute(/.*\.(?:woff|woff2|ttf|otf)/, args => {
  return fontHandler.handle(args);

Improving Font Performance: Subset fonts using Glyphhanger

Glyphhanger is a Node application that will help you reduce the size of your fonts by subsetting them.

There are times when we use just a few characters from a given font. For example, if we use a font just for headings there are many characters that you will not use, a subset will get rid of these unnecessary characters making the fonts smaller and providing a faster download. Another example is when the font supports multiple languages that you know you won’t need for your project (like Cyrillic when you know you will be working with English, Spanish, and French)

We will generate four different items from our font:

  • The list of glyphs to use
  • A Latin characters subset
  • A subset using only the characters in a given page
  • A subset using the characters in all the local pages

Generating a list of the glyphs to use

The first thing to look at is generating the list of glyphs you want to subset to. This is different than creating the subset fonts, it will only list the glyphs.

glyphhanger http://localhost:5000

You can also redirect the list to a file for later use

glyphhanger http://localhost:5000 > site-glyphs.txt

Subsetting to the Latin Character set

The first experiment is to subset the font to use Latin characters only. Latin alphabets are used in the United States, Latin America, and Western European countries

glyphhanger --latin \
--subset=Roboto-min-VF.ttf  \

This command will generate larger but more flexible subsets that will work with all pages in your site or application. But they are larger files, we can probably do better.

The next version will subset the font to use only the characters in the specified page, in this case, the index page for the site.

Subsetting to a page

glyphhanger http://localhost:5000 \
--subset=Roboto-min-VF.ttf  \

This will create subset fonts in the specified formats (WOFF and WOFF2) along with the CSS @font-face declaration that you can drop in your stylesheet to use. The fonts and CSS will only have the characters used in that page.

Subsetting with the spider

The previous subset is good for a single page application but breaks in multi-page sites as characters in the page we subset from may not be in the other pages in the site.

We can handle multiple pages by using Glyphhanger’s spidering capability by indicating that we want to use it (--spider) and the maximum number of URLs to capture (--spider-limit).

glyphhanger --spider --spider-limits-30 \
--formats=woff2,woff-zopfli \
--subset=*.ttf \

The result will be closer to the Latin subset but it will only contain the characters that exist in your site in the language it was written in. This becomes important when you use fonts like Roboto, Fira or other fonts designed to support multiple languages.

Looking at the sizes

Using Roboto Variable Font, downloaded from Github, as an example I got the following results when running the commands shown in prior sections.

Format File Name Size
TTF Roboto-min-VF.ttf 2.2MB
WOFF Roboto-min-VF.woff 1.3MB
WOFF Subset Roboto-min-VF-subset.zopfli.woff 104KB
WOFF Latin Subset Roboto-min-VF-latin-subset.zopfli.woff 109KB
WOFF Site Subset Roboto-min-VF-site-subset.zopfli.woff 1.3MB
WOFF2 Roboto-min-VF.woff2 976KB
WOFF2 Subset Roboto-min-VF-subset.woff2 86KB
WOFF2 Latin Subset Roboto-min-VF-latin-subset.woff2 977KB
WOFF2 Site Subset Roboto-min-VF-site-subset.woff2 92KB

The sizes are larger than you may expect because this is a variable font. It has all different instances of Roboto built in a single file so it’s all you would add to handle all of Roboto’s instances and Open Type functionality.

Improving Font Performance: Introduction and the bulletproof syntax

Talking about web font performance is more than just talking about adding the fonts to a page using @font-face. That’s just the beginning of our quest for performant fonts.

Because of their size fonts tend to be some of the largest components of any web pages. According to the HTTP Archive, the median of requested fonts is 98KB for Desktop and 83.4KB for mobile.

Comparison of median font sizes from 2010 to today

Making our fonts performant is particularly important when working with large variable fonts; Roboto VF is 976KB when compressed with WOFF2 and, if not cached, can significantly impact the performance of the page.

Background: @font-face and downloadable fonts

We tend to think of downloadable fonts as a new phenomenon but it isn’t. In 1998 browsers started shipping support for the @font-face CSS declaration… now developers could use digital fonts in their pages, right?

Not quite. While browsers supported the syntax they supported different font formats: Netscape supported TrueDoc from Bitstream and Microsoft supported Embedded Open Type (EOT), which also provided a layer of encryption for their downloadable fonts.

The other problem was that you didn’t have to own fonts in order to use them as downloadable assets. There was no way for foundries (the companies that created fonts) or developers to restrict access to the downloadable fonts.

Many foundries were concerned about piracy even in the encrypted EOT format so, for a long time, they withheld licenses for downloadable fonts.

Without good fonts, the whole idea of downloadable fonts fell off developers’ radars and the whole idea lay dormant until 2009 when both Safari and Firefox (re)introduced @font-face on their browsers and the CSS Working Group introduced a standardized way to load fonts on the web.

While we had the specification for how to load fonts there was no standard font to use, we still had to account for all the different formats supported browsers at the time.

That’s where the bulletproof @font-face syntax came in. It tried to support all browsers so we only declare the font once with multiple formats to accommodate different browsers. Over the years there have been multiple versions of the syntax, dating back to 2009:

The next section explores how much it has evolved and whether we still need the full syntax to work with Modern browsers.

The evolution of the bulletproof syntax

The original bulletproof @font-face syntax, first documented in Paul Irish’s Bulletproof @font-face Syntax, looked something like this:

@font-face {
  font-family: Open Sans;
  src:  url("opensans.eot");
  src:  url("opensans.eot?#iefix") format("embedded-opentype"),
        url("opensans.woff") format("woff"),
        url("opensans.ttf") format("truetype"),
        url("opensans.svg#svgFontName") format("svg");

The original syntax accounted for the different formats browsers supported at the time and their idiosyncrasies like the double declaration of EOT fonts.

In No @font-face syntax will ever be bulletproof, nor should it be Zach Leat describes the evolution of the bulletproof syntax and what the rationale for the removals are. The final @font-face syntax is:

@font-face {
  font-family: Open Sans;
  src:  url(opensans.woff2) format("woff2"),
        url(opensans.woff) format("woff");

Browsers src attribute will use the first format that they support and, since most browsers that support WOFF2 will also support WOFF we want to place the smallest file first.

This assumes that most of your target users are in modern browsers, we’re forcing older browsers to use system fonts:

Make sure that you test your font stacks in your target browsers, particularly if you’re targeting emerging markets where users may be working with older versions of operating systems to avoid device upgrades and, potentially expensive, software updates.

Don’t cross the streams

Streams are a very interesting concept and a new set of tools for the web. The idea is that we can read and write, depending on the type of stream we’re using, chunks of content… either write them to a location or read them from a location. This will improve performance because we can start showing things to the user before it has completed loading.

The example below how we can asynchronously download and display content to the user. The problem with this, if you can call it that, is that fetch will wait to download the entire file before settling the promise and only then will populate the content into the page.

const url = '';
const response = await fetch(url);
document.body.innerHTML = await response.text();

Streams seek to provide a better way to fetch content and display it to the user. The content gets to the browser first and we can then render it to the user as it arrives rather than have to wait for all the content to arrive before display.

The example below does the following:

  1. Fetches the specified resource
  2. Creates a reader from the body of the response object
  3. Creates a readable stream
  4. In the reader’s start menu we create a push function to do the work and read the first chunk of the stream
  5. We create a TextDecoder that will convert the value of the chunk from Uint8 to text
  6. If we hit done it’s because there are no more chunks to read so we close the controller and return
  7. Enqueue means we add the chunk we read to the stream and then we append the decoded string to the page
  8. We call the function again to continue processing the stream until there are no more chunks to read and done returns true
  9. We return a new response with the stream as the value and a new Content-Type header to make sure it’s served as HTML
fetch("").then((response) => { // 1
const reader = response.body.getReader(); // 2
const stream = new ReadableStream({ //3
  start(controller) {
    function push() {{ done, value }) => { // 4

        let string = new TextDecoder("utf-8").decode(value); // 5

        if (done) { // 6
        controller.enqueue(value); // 7
        document.body.innerHTML += string;
    push(); // 8

  return new Response(stream, { // 9
    headers: {
      "Content-Type": "text/html"

This reader becomes more powerful the larger the document we feed it is.

Creating my own streams

The example above also illustrates some of the functions and methods of ReadableStream and controller. The syntax looks like this and we’re not required to use any of the methods.

let stream = new ReadableStream({
  start(controller) {},
  pull(controller) {},
  cancel(reason) {}
}, queuingStrategy);
  • start is called immediately. Use this to set up any underlying data sources (meaning, wherever you get your data from, which could be events, another stream, or just a variable like a string). If you return a promise from this and it rejects, it will signal an error through the stream
  • pull is called when your stream’s buffer isn’t full and is called repeatedly until it’s full. Again, If you return a promise from this and it rejects, it will signal an error through the stream. Pull will not be called again until the returned promise fulfills
  • cancel is called if the stream is canceled. Use this to cancel any underlying data sources
  • queuingStrategy defines how much this stream should ideally buffer, defaulting to one item. Check the spec for more information

And the controller has the following methods:

  • controller.enqueue(whatever) – queue data in the stream’s buffer.
  • controller.close() – signal the end of the stream.
  • controller.error(e) – signal a terminal error.
  • controller.desiredSize – the amount of buffer remaining, which may be negative if the buffer is over-full. This number is calculated using the queuingStrategy.

Playing with typography

If you don’t know I have a ton of different type and layout experiments in their own website. I’ll start sharing some of the demos I’ve been working on via Twitter and explain the code for some here.

The full demo is available in Codepen.


The HTML is as simple as it comes. It’s an h1 heading element inside a container div. We will use the container to place the title and the myTitle heading as the target for lettering.js

<div class='container'>
  <h1 class='myTitle'>Nightfall</h1>

This example uses only the heading. We could add more text and assume that this is the title for a document.


Unlike most of my projects, Lettering.js is a jQuery plugin. While I don’t normally use or recommend jQuery for production (it’s not a value judgment on jQuery, it’s just an additional dependency that is usually not needed) I’ll make an exception for this demo but will illustrate an alternative without jQuery and some of the problems I encountered when using it.

The first part of this section is to add jQuery. To do so I use a technique I learned from the HTML5 Boilerplate that works when jQuery is not present for whatever reason.

<script src=""
<script>window.jQuery ||
  document.write('<script src="/js/jquery-1.12.4.min.js"><\/script>'

We first load jQuery from a CDN as normal. In this case, I’ve chosen jQuery’s own CDN.

As soon as we load it we check for the global window.jQuery object. If it exists we use it, otherwise, we use document.write to dynamically create a link to a local version of the script matching the version we get from CDN.

Since jQuery is still popular we will seldom encounter this issue in existing projects but brand new projects, particularly when starting in your workstation.

Next, we load Lettering.js and initialize it.

<script src='js/jquery.lettering.js'></script>
  $(document).ready(function() {

The rest of the work is done in CSS.

We first import that Typekit project that we want to use. Typekit recommends using the link element to load the stylesheet but I want to make sure that the font is available before we do all the manipulation.

When defining the body element, I set the overall background color and the default font for the document, which is not the font we’ll be using for the heading; this is on purpose.

@import url("");

body {
  background-color: #fbfbf6;
  font-family: Raleway, sans-serif;

The container element is where the magic starts. We set up a linear gradient for the background, the height and width for the element, the font size, and the breaking behavior.

Because we will treat each letter as its own container we want to break whenever we need to.

One last item regarding the container. I’ve omitted the vendor-prefixed syntax. Depending on what browsers you must support I recommend testing this to make sure that they support the syntax you provide for the gradient.

.container {
  margin-top: -1.25em;
  background-color: rgb(33, 35, 66);
  background: linear-gradient(to bottom, #212342 0%, #fff 100%);
  color: rgb(255, 255, 255);
  height: 100%;
  width: 65%;
  word-break: break-all;
  overflow-wrap: break-word;

For the h1 element we do a few things: We set up the font we want to use, we make it all uppercase, we set up the line height to be closer than normal and finish by adding padding to the element so it won’t be flush against the margins and lose some of the text shadow effects.

All spans elements that Lettering.js generates will get display: relative so we can play with moving them around.

h1 {
  font-family: 'bebas-neue', sans-serif;
  text-transform: uppercase;
  line-height: .65em;
    padding: .05em;

span {
  position: relative;

Lettering will dynamically inject a span element with a class equal to char plus a number indicating the location of the letter in the word we initialized.

They all have three attributes in common:

  • z-index to indicate the stacking order among the letters; larger positive numbers indicate a higher position in the stack, closer to the viewer and negative numbers indicate lower positions in the stack, away from the viewer
  • text-shadow produces a shadow from the source element. Parameters are: offset-x (x-axis blur distance from the text), offset-y (y-axis blur distance from the text), blur-radius (the bigger the blur the wider and lighter it becomes) and color (the color of the shadow)
  • margin-left to indicate how close letters are to each other

We can add other elements to individual characters as needed to get the effect that we wanted. One idea I’ve been playing with is to use SASS to generate random colors for each letter.

.char1 {
  z-index: 4;
  text-shadow: -0.02em 0.02em 0.2em rgba(10, 10, 10, .8);
  margin-left: -0.05em;

.char2 {
  z-index: 3;
  text-shadow: -0.02em 0.02em 0.2em rgba(10, 10, 10, .8);
  margin-left: -0.025em;
  top: 0.05em;

.char3 {
  z-index: 9;
  text-shadow: -0.02em 0.02em 0.05em rgba(10, 10, 10, .8);
  margin-left: -0.05em;

.char4 {
  z-index: 5;
  text-shadow: 0.01em -0.01em 0.14em rgba(10, 10, 10, .8);
  margin-left: -0.05em;
  top: -0.01em;

.char5 {
  z-index: 2;
  text-shadow: -0.02em -0.02em 0.14em rgba(10, 10, 10, .8);
  margin-left: -0.06em;
  top: 0.02em;

.char6 {
  z-index: 10;
  text-shadow: -0.02em -0.02em 0.14em rgba(10, 10, 10, .8);
  margin-left: -0.06em;
  top: -0.02em;

.char7 {
  z-index: 8;
  text-shadow: -0.02em -0.02em 0.14em rgba(10, 10, 10, .8);
  margin-left: -0.05em;

.char8 {
  z-index: 6;
  text-shadow: -0.02em -0.02em 0.14em rgba(10, 10, 10, .8);
  margin-left: -0.08em;
  top: -0.02em;

.char9 {
  z-index: 7;
  text-shadow: -0.02em -0.02em 0.14em rgba(10, 10, 10, .8);
  margin-left: -0.08em;

One last aspect is to make sure that it looks decent in our target devices and browsers. I have to look at it in an iPad and iPhone to make sure.

Non jQuery Alternative

Based on Jeremy Keith’s gist this is a quick way to do some of the slicing and span/class addition without having to use jQuery.

The HTML and CSS remain the same, although we may have to tweak the CSS to make it look identical. The Javascript changes to the code shown below:

 function sliceString(selector) {
    if (!document.querySelector) return;
    var string = document.querySelector(selector).innerText,
        total = string.length,
        html = '';
    for (var i=0; i<total ; i++) {
        html+= `<span class="char${i+1}">${string.charAt(i)} `;
    document.querySelector(selector).innerHTML = html;

This needs further testing, particularly in Firefox where some users of Jeremy’s code reported problems