Photo by Jake Givens on Unsplash
Let Service Workers Help Speed Up Your Site Performance by 10X
While also avoiding their notoriously difficult pain-points!
As I was recently converting a personal project into a PWA, I started to learn about service workers. I knew a little bit, but after some more digging (and even more debugging), here's the summary of what you should take away:
Service Workers are all about CACHING; it's what they live for!
Service Workers have a bad rap because when implemented incorrectly, they can become overly "sticky" and make code updates to your users' existing/cached app code essentially impossible -- that's the worst-case scenario π (same for caching in general).
All caching strategies have trade-offs (see my previous article about two common patterns). But generally, the most generic and safe bet is SWR (stale-while-revalidate), which looks exactly like this in a Service Worker:
// An SWR fetch strategy:
// See: https://web.dev/offline-cookbook/#stale-while-revalidate
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.open(RUNTIME).then(async (cache) => {
const cachedResponsePromise = await cache
.match(event.request)
.catch(() => caches.match("/offline"));
const latestResponsePromise = fetch(event.request).then(
(freshResponse) => {
if (event.request.method === "GET" && freshResponse.ok) {
cache.put(event.request, freshResponse.clone());
}
return freshResponse;
},
);
return cachedResponsePromise || latestResponsePromise;
}),
)
});
Now then, let's go through that powerful code snippet in much more detail:
#1: Register a Service Worker
A Service Worker file is kind of like a "hosts" file on your machine to resolve host names; however, a Service Worker tells your browser how to resolve all your app's network requests. It's a very powerful layer of abstraction that wise developers can leverage to their "Fast-App" advantage.
Here's a quick diagram:
By default, the app's service worker will just fetch everything, every time. Now, let's register our custom service worker, and make impressive performance optimizations. (It's not as hard as it might sound atm... so keep reading and try it out!)
First, let's "hook in" by putting this Service Worker registration code somewhere in the initial bootstrap portion of your app -- perhaps in a script
tag directly in your base index.html
file, if you can:
<!-- SERVICE WORKER -->
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.getRegistrations()
.then((rs) => Promise.all(rs.map((r) => r.unregister())))
.then(() => {
navigator.serviceWorker.register("sw.js");
});
}
</script>
Some of that code helps to clean up any old service worker definitions (which can otherwise be a nightmare) and luckily doesn't really hurt anything. (But if it actually does.... leave us a comment below!) But the critical code is this:
navigator.serviceWorker.register(X)
which must contain the relative URL to your actual Service Worker file. For this tutorial, we'll create it in the root of our public folder, in a file called "sw.js". Thus we'll use navigator.serviceWorker.register("sw.js")
.
#2: Add a Default Service Worker Definition
So now, create a new "sw.js" file in your public root folder, then add to it this boilerplate code:
That's an OK start; BUT the fetch
event handler (i.e. the part that is self.addEventListener('fetch'
) is way too over-aggressive; that is, once a network file is initially cached by the user's app, it will always π― be served up, and never updated with new data. That strategy is extremely "efficient", but also way too "sticky"!! (You can never update your user's app/site cache π¦)
#3: Implement an "SWR" Fetch Strategy
Instead, we replace that strategy with the code listed at the start of this article; this way, we will get moments to update the network cache. To break it down, let's start at the beginning:
const PRECACHE = "precache-v1"
const RUNTIME = "runtime-swr-v1"
const PRECACHE_URLS = ["/offline"]
// ...snip!...
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.open(RUNTIME).then(async (cache) => {
...
This handler tells the service worker how to handle every fetch
request from the app. Very powerful.
Note: this includes more than just JavaScript code that calls
fetch().then(...) etc.
; this also includes every single browser resource/asset request (likeindex.html
andfav.ico
and everything)!
Notice the line with a caches
references. Here, we can see a clear peek into the purpose of Service Workers -- caching data! So, we tell it to use a cache to store all its runtime resources, identified by this id:
const RUNTIME = "runtime-swr-v1"
This next part tells the Service Worker to find any (stale) cached resource for the request:
const cachedResponsePromise = await cache
.match(event.request)
.catch(() => caches.match("/offline"));
Note: If a major request error occurs, we'll catch it and just assume that the user is offline (which might not actually be the case--discretion advised!!) and we'll serve up a minimal "offline" HTML page to inform the user. See link.
This next section is the crux of SWR:
// DO NOT AWAIT on this latest response promise below!!
const latestResponsePromise = fetch(event.request).then(
(freshResponse) => {
// SWR cycle:
// Always update the cache with
// latest GET responses (eventually!)
if (event.request.method === "GET" && freshResponse.ok) {
cache.put(event.request, freshResponse.clone());
}
return freshResponse;
},
)
This is so cool because every time the app requests a network resource, we'll also make a fetch
for the request's latest data. When that network response is received, we then populate our cache with the latest good data. This is exactly what we want to make sure of -- that our asset cache is never stale for too long!
Note: Service Workers can only cache "GET" method requests.
Finally, this next line is what makes SWR extremely fast:
// SWR cycle:
// Always try to return the stale/cached response first
return cachedResponsePromise || latestResponsePromise;
What's happening here is that we're telling the service worker: "I'd rather serve up assets from my (fast) cache; but if I don't have anything saved yet, then serve up the latest (slower) data response". That's the magic for going so zippy fast.
In practical terms, looking at the browser DevTool's network tab, this is an example of what's happening in the browser:
If you look closely, do you see how the browser is getting back all its cached asset responses in < 13 ms? But the service worker's live asset fetch responses (shown with the cogs icons beside) are taking ~5-20x longer? Bada-bing! β¨
Note: Of course, getting a < 10 ms network response is practically impossible! So this observed response time result via SWR caching is the next-best-option, IMO.
Trade-offs
It's important to consider and understand the trade-offs. In this case, the main trade-off is that when your user's app refreshes the first time (i.e. a repeat visit), it'll always get the previous/stale app code served up from the Service Worker cache (which is fast but stale). However, by the next time the app refreshes, all the latest assets have been updated into the cache in the background; so they'll now see the newest code/app/site.
In short: A double/second refresh is the only price for near-instant request/asset fetching. I think that's π― fantastic. π π
I hope you enjoyed this tutorial! Understanding these advanced browser caching tools and techniques and concepts will give you additional tools for many other situations, so they're π― worth mastering!
You can follow me on Twitter and YouTube. Letβs continue interesting conversations about Sr JS development together!