The Service Worker API is the Dremel of the web platform. It offers incredibly broad utility while also yielding resiliency and better performance. If you’ve not used Service Worker yet—and you couldn’t be blamed if so, as it hasn’t seen wide adoption as of 2020—it goes something like this:
- On the initial visit to a website, the browser registers what amounts to a client-side proxy powered by a comparably paltry amount of JavaScript that—like a Web Worker—runs on its own thread.
- After the Service Worker’s registration, you can intercept requests and decide how to respond to them in the Service Worker’s
fetch()
event.
What you decide to do with requests you intercept is a) your call and b) depends on your website. You can rewrite requests, precache static assets during install, provide offline functionality, and—as will be our eventual focus—deliver smaller HTML payloads and better performance for repeat visitors.
Getting out of the woods
Weekly Timber is a client of mine that provides logging services in central Wisconsin. For them, a fast website is vital. Their business is located in Waushara County, and like many rural stretches in the United States, network quality and reliability isn’t great.
Wisconsin has farmland for days, but it also has plenty of forests. When you need a company that cuts logs, Google is probably your first stop. How fast a given logging company’s website is might be enough to get you looking elsewhere if you’re left waiting too long on a crappy network connection.
I initially didn’t believe a Service Worker was necessary for Weekly Timber’s website. After all, if things were plenty fast to start with, why complicate things? On the other hand, knowing that my client services not just Waushara County, but much of central Wisconsin, even a barebones Service Worker could be the kind of progressive enhancement that adds resilience in the places it might be needed most.
The first Service Worker I wrote for my client’s website—which I’ll refer to henceforth as the “standard” Service Worker—used three well-documented caching strategies:
- Precache CSS and JavaScript assets for all pages when the Service Worker is installed when the window’s load event fires.
- Serve static assets out of
CacheStorage
if available. If a static asset isn’t inCacheStorage
, retrieve it from the network, then cache it for future visits. - For HTML assets, hit the network first and place the HTML response into
CacheStorage
. If the network is unavailable the next time the visitor arrives, serve the cached markup fromCacheStorage
.
These are neither new nor special strategies, but they provide two benefits:
- Offline capability, which is handy when network conditions are spotty.
- A performance boost for loading static assets.
That performance boost translated to a 42% and 48% decrease in the median time to First Contentful Paint (FCP) and Largest Contentful Paint (LCP), respectively. Better yet, these insights are based on Real User Monitoring (RUM). That means these gains aren’t just theoretical, but a real improvement for real people.
This performance boost is from bypassing the network entirely for static assets already in CacheStorage
—particularly render-blocking stylesheets. A similar benefit is realized when we rely on the HTTP cache, only the FCP and LCP improvements I just described are in comparison to pages with a primed HTTP cache without an installed Service Worker.
If you’re wondering why CacheStorage
and the HTTP cache aren’t equal, it’s because the HTTP cache—at least in some cases—may still involve a trip to the server to verify asset freshness. Cache-Control’s immutable
flag gets around this, but immutable
doesn’t have great support yet. A long max-age value works, too, but the combination of Service Worker API and CacheStorage
gives you a lot more flexibility.
Details aside, the takeaway is that the simplest and most well-established Service Worker caching practices can improve performance. Potentially more than what well-configured Cache-Control
headers can provide. Even so, Service Worker is an incredible technology with far more possibilities. It’s possible to go farther, and I’ll show you how.
A better, faster Service Worker
The web loves itself some “innovation,” which is a word we equally love to throw around. To me, true innovation isn’t when we create new frameworks or patterns solely for the benefit of developers, but whether those inventions benefit people who end up using whatever it is we slap up on the web. The priority of constituencies is a thing we ought to respect. Users above all else, always.
The Service Worker API’s innovation space is considerable. How you work within that space can have a big effect on how the web is experienced. Things like navigation preload and ReadableStream
have taken Service Worker from great to killer. We can do the following with these new capabilities, respectively:
- Reduce Service Worker latency by parallelizing Service Worker startup time and navigation requests.
- Stream content in from
CacheStorage
and the network.
Moreover, we’re going to combine these capabilities and pull out one more trick: precache header and footer partials, then combine them with content partials from the network. This not only reduces how much data we download from the network, but it also improves perceptual performance for repeat visits. That’s innovation that helps everyone.
Grizzled, I turn to you and say “let’s do this.”
Laying the groundwork
If the idea of combining precached header and footer partials with network content on the fly seems like a Single Page Application (SPA), you’re not far off. Like an SPA, you’ll need to apply the “app shell” model to your website. Only instead of a client-side router plowing content into one piece of minimal markup, you have to think of your website as three separate parts:
- The header.
- The content.
- The footer.
For my client’s website, that looks like this:
The thing to remember here is that the individual partials don’t have to be valid markup in the sense that all tags need to be closed within each partial. The only thing that counts in the final sense is that the combination of these partials must be valid markup.
To start, you’ll need to precache separate header and footer partials when the Service Worker is installed. For my client’s website, these partials are served from the /partial-header
and /partial-footer
pathnames:
self.addEventListener("install", event => {
const cacheName = "fancy_cache_name_here";
const precachedAssets = [
"/partial-header", // The header partial
"/partial-footer", // The footer partial
// Other assets worth precaching
];
event.waitUntil(caches.open(cacheName).then(cache => {
return cache.addAll(precachedAssets);
}).then(() => {
return self.skipWaiting();
}));
});
Every page must be fetchable as a content partial minus the header and footer, as well as a full page with the header and footer. This is key because the initial visit to a page won’t be controlled by a Service Worker. Once the Service Worker takes over, then you serve content partials and assemble them into complete responses with the header and footer partials from CacheStorage
.
If your site is static, this means generating a whole other mess of markup partials that you can rewrite requests to in the Service Worker’s fetch()
event. If your website has a back end—as is the case with my client—you can use an HTTP request header to instruct the server to deliver full pages or content partials.
The hard part is putting all the pieces together—but we’ll do just that.
Putting it all together
Writing even a basic Service Worker can be challenging, but things get real complicated real fast when assembling multiple responses into one. One reason for this is that in order to avoid the Service Worker startup penalty, we’ll need to s