Supercharge Google Fonts with Cloudflare and Service Workers

Pier-Luc Gendreau
5 min readOct 3, 2018

--

Just recently, I wrote about improving performance with Google Fonts. Well, after some exploration, it turns out that was just the beginning and we can do a lot better. How much better exactly? Let’s say it’s possible to significantly cut latency for new visitors and instantly load fonts for returning visitors. All this while keeping Google Fonts’ stellar cross-browser compatibility.

“time lapse photograph of train under starry sky” by Avery Lewis on Unsplash

TLDR; I’ve edited the code in the article for brevity, check out my font-workers repository on GitHub to see what Jirafe runs in production.

As a quick recap, here’s what typically happens when using Google Fonts:

  1. The browser blocks all text rendering while it waits for necessary fonts
  2. Google returns a CSS file containing font declarations for that browser
  3. Text rendering is still blocked while the browser requests the actual fonts

In the previous article, I used Cloudflare Workers to add a proxy in front of Google Fonts to transparently inject font-display: swap; to the @font-face declarations which allows browsers to fallback on and display native fonts until the real fonts come back from the network. That improved First Contentful Paint quite a bit but actually added an additional network hop and a bit of unpleasant flash of unstyled text.

Want to learn more about more about the inner workings of font-display? CSS-Tricks is a great place to start!

On the edge

Alright so instead of adding a proxy, what if it was somehow possible to directly inline critical font declarations into our pages’ <head>? Once again, Cloudflare Workers to the rescue!

addEventListener("fetch", event => {
event.passThroughOnException()
event.respondWith(handleRequest(event.request))
})

This is the basis of every worker, it listen for fetch events and then passes on the incoming request to our handler. To prevent JavaScript errors from causing entire requests to fail, Cloudflare provides passThroughOnException which will cause the worker to act as if it wasn’t there in case of an uncaught exception.

For more information on error handling and logging within Workers, Cloudflare has a great blog article detailing what they use in production!

With that out of the way, let’s get back to fonts! The handler first checks what kind of content the browser is looking for. For HTML requests, it will fetch that page and concurrently fetch our chosen fonts using the request’s User-Agent thus ensuring Google sends back the correct format. For all other requests, the worker simply sends back the response without any further actions.

async function handleRequest(request) {
const isHtml = request.headers.get("accept").includes("text/html")
const [response, fontResponse] = await Promise.all([
fetch(request),
isHtml ? fetch("https://fonts.googleapis.com/css?family=Lato:300,400,700,900", {
headers: {
"User-Agent": request.headers.get("user-agent")
}
}) : null
])
if (!fontResponse) {
return response
}
// ...end of part one!
}

With the HTML and font responses in hand, the worker can start working its magic! It starts by parsing both as text and, using the same technique as the previous article, replaces every closing bracket in the CSS with font-display: swap; — remember, we’re dealing with @font-face declarations here. The worker then inlines that CSS into a style tag at the end of the HTML’s head, which saves the browser a precious request and significantly improves performance as it can start requesting font files as soon as it receives the initial payload. That alone is a significant performance for first time visitors!

async function handleRequest(request) {
// ...start of part two!
const html = await response.text();
const css = await fontResponse.text();
const style = css.replace(/}/g, "font-display: swap; }");
return new Response(html.replace(/<\/head>/, `<style>${style}</style></head>`), {
headers: response.headers
});
}

On the client

With this powerful and transparent inlining of fonts in place, it’s time to leverage the browsers’ Service Workers! Keep in mind that the fastest network request is the one you never make so after the fonts have been delivered once, we’ll keep them cached locally. This works well for fonts because they have the convenient attribute of being (generally) immutable, no tricky expiration mechanisms required.

First off, a service worker has to be be registered. For static sites, this might happen right before the closing </body> tag. For React or Vue applications, it’s likely to happen inside one of the top-level component’s componentDidMount or mounted lifecycle hooks. Either way, registration code will be be very close or identical.

if (!"serviceWorker" in navigator) {
return;
}
try {
await navigator.serviceWorker.register("/sw.js")
} catch (e) {
console.warn("Service worker registration failed", e)
}

One important detail to note, the file containing the service worker’s code itself should reside at the root of your domain, for example: https://example.com/sw.js. Onto the worker itself, it starts by adding a listener for fetch events, which essentially adds a proxy in-between the browser and the network. Since Cloudflare Workers are directly modeled on that same API, you may notice this is almost exactly the same we did on the edge.

self.addEventListener("fetch", event => {
event.respondWith(handleRequest(event.request))
})

The handler itself, however, is quite different. The Cache API is basically a key/value store where keys are requests and values are responses. First, a cache namespace is opened and then it looks for an entry matching the currently outgoing request. If there’s a match, awesome, we can return it immediately! If not, the worker has to perform the actual request using fetch and then inspect the response. After receiving a successful response, the worker looks at headers for hints of a font and if there’s a match, store it in cache before returning it.

async function handleRequest(request) {
const cache = await caches.open("fonts");
const cacheResponse = await cache.match(request);

if (cacheResponse) {
return cacheResponse;
}
const response = await fetch(request); if (
response.status < 400 &&
response.headers.has("content-type") &&
response.headers.get("content-type").match(/(^application\/font|^font\/)/i)
) {
cache.put(request, response.clone());
}
return response;
}

With both pieces in place, your visitors will be able to enjoy lightning fast fonts with an almost indiscernible flash of unstyled text. You’ll also save returning visitors a bit of bandwidth by completely avoiding the network.

You can see the technique in action on Jirafe, which now scores quite well on Google’s Lighthouse audit! 🚀

Any thoughts on the approach or different ideas to further improve it?

--

--

Pier-Luc Gendreau

🦄 JavaScript Developer at Classcraft 🚀 Creating Jirafe, a Slack bot for Jira 🦒