Caching strategies for Front-End developers using a service worker
Last week I published cache-busting guides and how we can fix the problem of cached files in the browser specifically when users are online and using our app while we deploy a new version of our service. The fifth solution was to use caching strategies using a service worker which on its own is a comprehensive and big article so I decided to publish another one here. ( previous article is linked in the below )
What is a Service worker:
If you are not familiar with service workers, I highly recommend reading important documentation and articles about them, especially their lifecycle and how they work, here is a good one:
https://web.dev/articles/service-worker-lifecycle
For our case, this is my basic config for the service worker.
Create a sw.js or whatever name you prefer in JavaScript and put it in the root or public folder of your project where it can be accessed by /sw.js in your project.
const version = "1.0.0";
const cacheName = `cache-${version}`;
const versionCacheName = "cache-v";
// ***** Installation *****
self.addEventListener("install", (ev) => {
console.log("SW is installed");
const channel = new BroadcastChannel("sw-messages");
channel.postMessage({ version });
});
// This allows the web app to trigger skipWaiting
self.addEventListener("message", (ev) => {
if (ev?.data?.type === "SKIP_WAITING") self.skipWaiting();
});
// ***** Activation *****
self.addEventListener("activate", (e) => {
console.log("SW is activated");
e.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter((key) => key !== cacheName).map((nm) => caches.delete(nm)),
);
}),
);
});
// ***** Fetching *****
self.addEventListener("fetch", (ev) => {
});
The purpose of the service worker here is to put an event listener on fetch requests happening on our web pages and act upon their specifications accordingly.
Do not forget to put the service worker in the public folder of your application since we are accessing it by /sw.js and here is the code where we register this service worker in our project, it can be in the index.tsx, app.tsx, or the other way would be to use it inside a custom hook which is possible as well but we’re not going through it here:
document.addEventListener("DOMContentLoaded", async () => {
if ("serviceWorker" in navigator) {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(
() => {
registration.update();
},
5 * 60 * 1000,
);
registration.addEventListener("updatefound", () => {
console.log("SW update found!");
registration?.waiting?.postMessage({
type: "SKIP_WAITING",
});
});
} catch (err) {
console.log("SW registration failed", err);
}
}
});
Alright, now that we have our service worker boiler setup let’s take a deep dive into caching strategies and how they work, this is very exciting!
Caching strategies: a frightening term but very easy to learn!
1- Cache only:
In this strategy, the criteria are very simple because we are forcing our requests to be resolved only from the cache of our browser and the network is completely out of the flow like the below figure.
self.addEventListener("fetch", (ev) => {
ev.respondWith(cacheOnly(ev));
});
// Only return what is in the cache
async function cacheOnly(ev) {
return caches.match(ev.request);
}
2- Cache first and fallback on a network:
In this strategy, we try to server requests from the cache at the beginning, if there is a cache available for them then we return it but if there is no cache we will try to fall back to the network and get it from the network and cache it.
(1): The request hits the cache. If the asset is in the cache, serve it from there.
(2): If the request is not in the cache, go to the network.
(3): Once the network request finishes, return the response from the network.
(4): Add network response to the cache.
self.addEventListener("fetch", (ev) => {
ev.respondWith(cacheFirst(ev));
});
// Try cache and fallback on network
async function cacheFirst(ev) {
try {
// Return the cache response if it is not null
const cacheResponse = await caches.match(ev.request);
if (cacheResponse) return cacheResponse;
// If no cache, fetch and cache the result and return result
const fetchResponse = await fetch(ev.request);
const cache = await caches.open(cacheName);
await cache.put(ev.request, fetchResponse.clone());
return fetchResponse;
} catch (err) {
console.log("Could not return cache or fetch CF", err);
}
}
3- Network only:
This is very similar to the cache-only strategy in an opposite way, in this strategy we always rely on the network and the cache is out of the way.
self.addEventListener("fetch", (ev) => {
ev.respondWith(networkOnlky(ev));
});
// Only return fetch
function networkOnly(ev) {
return fetch(ev.request);
}
4- Network first and fallback on a cache:
This one is very similar to the cache first strategy but in the opposite way. We always put the network on our first attempt and will cache it but if the network fails, we will serve from the cache, this is very good for offline availability in apps.
(1): The request goes to the network.
(2): If the request is a success to the network, serve it from there.
(3): If the network request fails or we are offline, attempt to return the response from the cache.
(4): If the network request was a success, always cache the network response for future use.
self.addEventListener("fetch", (ev) => {
ev.respondWith(networkRevalidateAndCache(ev));
});
// Try network first and fallback on cache
async function networkRevalidateAndCache(ev) {
try {
const fetchResponse = await fetch(ev.request);
if (fetchResponse.ok) {
const cache = await caches.open(cacheName);
await cache.put(ev.request, fetchResponse.clone());
return fetchResponse;
} else {
const cacheResponse = await caches.match(ev.request);
return cacheResponse;
}
} catch (err) {
console.log("Could not return cache or fetch NF", err);
}
}
5- Stale while revalidate:
Of the strategies we’ve covered so far, “Stale-while-revalidate” is the most complex one and it is a combination of network first and cache first strategies. The procedure prioritizes speed of access for a resource, while also keeping it up to date in the background. This strategy goes something like this:
(1): The request is prioritized to go to the cache and serve it from there very fast.
(2): If the cache request misses or hits we will always try to request to the network in the background as well.
(3): For the first time where there is no cache, we will get the response from the network and cache it, and at other times it will always be served from the cache.
(4): We always cache network requests to have a revalidation and store the latest version.
self.addEventListener("fetch", (ev) => {
ev.respondWith(staleWhileRevalidate(ev));
});
// Always try cache and network in parallel and revalidate the response
async function staleWhileRevalidate(ev) {
try {
// Return the cache response
// Revalidate as well and update cache
const [cacheResponse, fetchResponse, cache] = await Promise.all([
caches.match(ev.request),
fetch(ev.request),
caches.open(cacheName),
]);
await cache.put(ev.request, fetchResponse.clone());
return cacheResponse || fetchResponse;
} catch (err) {
console.log("Could not return and fetch the asset CF", err);
}
}
A very important note on fetch event listeners in service workers:
If we try to break the fetch requests in the fetch event listener, simply it would be like this:
self.addEventListener("fetch", (ev) => {
const { mode } = ev.request;
const selfURL = self.location;
const url = new URL(ev.request.url);
const isOnline = self.navigator.onLine;
const isAsset = url.pathname.includes("/assets/");
const isHTML = ev.request.mode === "navigate";
const isDomJS = url.pathname.includes("dom.js");
const isJS = isAsset && url.pathname.includes(".js");
const isImage = ["jpg", "jpeg", "png"].some((mim) =>
url.pathname.includes(`.${mim}`),
);
const isCSS = isAsset && url.pathname.includes(".css");
const isJSON = isAsset && url.pathname.includes(".json");
const isFont =
isAsset &&
["woff", "woff2", "ttf", "otf"].some((mim) =>
url.pathname.includes(`.${mim}`),
);
const isExternal = mode === "cors" || url.hostname !== selfURL.hostname;
if (isOnline) {
if (isImage || isJSON || isFont) ev.respondWith(cacheFirst(ev));
if (!isDomJS && !isHTML) {
if (isJS || isCSS) {
ev.respondWith(networkFirst(ev));
}
}
} else {
ev.respondWith(cacheOnly(ev));
}
});
We can separate the assets and their types by inspecting the request specifications, for example, if this file is JavaScript, HTML, CSS, or Image or it is an external resource or internal asset, is it JSON or Font, these categories are simply configurable and we will be able to try different caching strategies on any of them separately for example in the above sample I am applying cache first strategy to images and fonts and applying network first strategy to JS and CSS files and if the user is offline, I try the cache only to serve offline assets to the user.
You can combine all of these to find the best match according to your business needs and the type of application you are building.
How to notify the user about the version change?
If you remember from the beginning of this article, we’ve written an install event listener that listens to when the service worker will be installed, in fact, every time a service worker file changes and it is not BYTE-TO-BYTE identical to the previous version, it will install the new version and stale the previous one and this event will be triggered.
const version = "1.0.0";
const cacheName = `cache-${version}`;
const versionCacheName = "cache-v";
// ***** Installation *****
self.addEventListener("install", (ev) => {
console.log("SW is installed");
const channel = new BroadcastChannel("sw-messages");
channel.postMessage({ version });
});
We need to put a mechanism in our CI/CD pipeline using tools to manipulate the version variable in our service worker file ( or apply a similar mechanism ) then broadcast the version change to the client and the client will be notified to reload the app by pressing a button because we are sure a new version of this app is available.
We will then remove the previous cache version and create a new cache accordingly as you see in the above code snippet.
The most important thing here is that after you register the service worker you need to set an interval time to check if an update is available or not for example here:
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(
() => {
registration.update();
},
5 * 60 * 1000,
);
registration.addEventListener("updatefound", () => {
console.log("SW update found!");
registration?.waiting?.postMessage({
type: "SKIP_WAITING",
});
});
} catch (err) {
console.log("SW registration failed", err);
}
I’ve set a 5-minute interval to check for the update in the service worker file from the server, this can be modified according to your needs. the skipWaiting event is to force the previous service worker to shut down and let the new version be installed.
I hope this article will be helpful to you, Please feel free to comment and ask questions and follow me for future content.
Good Resources to read: