In the previous post, we added the necessary files and configuration to turn our Rails app into a Progressive Web App (PWA). By the end of this tutorial, you will have a service worker that can cache key assets and pages, and provide a sensible offline fallback to ensure your users have a smooth offline experience.
Writing a service worker using Workbox
Note: For this tutorial I will assume that you understand the basics of service workers functionalities and lifecycle. If that’s not the case, this is a good place to start.
According to its documentation “Workbox is a set of modules that simplify common service worker routing and caching.”. In other words, Workbox makes writing a service worker much easier as if we were to implement these functions ourselves.
How to import the modules
The easiest way I found to import the Workbox library into my project was directly through CDN. This translates into the following code in the first line of the service worker:
// app/views/service_worker/service_worker.js
importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js"
);
Caching with Workbox
In a regular web app, we’re constantly fetching resources from the server through requests. In a PWA, these requests are intercepted by the service worker, so we can handle requests differently given certain conditions, such as network availability or characteristics of the request. For example, we could tell the service worker to respond to an image request by retrieving a cached response when there is no connection available. But to be able to to this, we need to have previously cached this image when we were connected to the internet so it’s available when we lose connection.
Workbox provides a set of strategies for caching, which are commonly used patterns to determine how the service worker will generate a response when a fetch event is received. The way we tell our service worker how to handle a request is by “routing” it to the particular strategy we want to use. And for this we will use another handy Workbox module called...surprise, routing.
You can choose the strategies and routing that best fit your needs. This is how an example implementation looks:
// app/views/service_worker/service_worker.js
// We first define the strategies we will use and the registerRoute function
const {CacheFirst, NetworkFirst} = workbox.strategies;
const {registerRoute} = workbox.routing;
// If we have critical pages that won't be changing very often, it's a good idea to use cache first with them
registerRoute(
({url}) => url.pathname.startsWith('/home'),
new CacheFirst({
cacheName: 'documents',
})
)
// For every other page we use network first to ensure the most up-to-date resources
registerRoute(
({request, url}) => request.destination === "document" ||
request.destination === ""
new NetworkFirst({
cacheName: 'documents',
})
)
// For assets (scripts and images), we use cache first
registerRoute(
({request}) => request.destination === "script" ||
request.destination === "style",
new CacheFirst({
cacheName: 'assets-styles-and-scripts',
})
)
registerRoute(
({request}) => request.destination === "image",
new CacheFirst({
cacheName: 'assets-images',
})
)
Adding an offline fallback
We have our cache strategies set up. However, what would happen if we’re offline and the service worker intercepts a request that does not match any of the cached responses? In a regular web scenario, we would see the browser’s fallback telling us we have lost connection. But since we want to provide a smooth offline experience, it’s a good idea to have our own offline fallback, this is, a cached page that would let our users know that they’re not connected within the UX of the app.
Workbox comes with a recipe for offline fallback, but I had some problems when trying to implement it, basically because you need to specify a default handler for your routes. In the example they provide, the recipe uses a network-first strategy for all requests and sends the offline fallback when there is no response from the network. But in my case, a default handler would cause more problems than it would solve, as I already had defined the strategies I needed for specific types of requests (see previous code block). So I implemented my own version of the offline fallback:
// app/views/service_worker/service_worker.js
const {warmStrategyCache} = workbox.recipes;
const {setCatchHandler} = workbox.routing;
const strategy = new CacheFirst();
const urls = ['/offline.html'];
// Warm the runtime cache with a list of asset URLs
warmStrategyCache({urls, strategy});
// Trigger a 'catch' handler when any of the other routes fail to generate a response
setCatchHandler(async ({event}) => {
switch (event.request.destination) {
case 'document':
return strategy.handle({event, request: urls[0]});
default:
return Response.error();
}
});
In this code block, I use WarmCacheStrategy to “warm” the runtime cache with critical urls during the service worker’s install event, so I can be sure the offline.html page will be in the cache when I need it. I use the CacheFirst strategy to tell the service worker to always look for the response in the cache before trying to fetch it from the network.
Then I register this cached response as fallback using the setCatchHandler function, which is like telling the service worker “use this whenever you fail to generate a response from the previously defined routes”.
Next steps
In the next and last post of this series, we will enable our users to create new records while being offline and synchronize them to the server once the connection is reestablished. To achieve this, we will use IndexedDB and Stimulus (which comes by default in Rails since its version 7). Stay tuned!