Make Your PWA Work Offline Part 1—Static Files Guide

Mateusz Adamczyk

offline

Imagine you’re on a long trip and want to pass the time doing something pleasant. Or you’re on a business trip, visiting your client’s offices. You pull up your favorite mobile app or one that you need to do business (one app could be both of those, though) and… it fails for good, refusing to work properly due to the Internet connection being spotty. So, you either do overtime waiting for websites to load, decide to give it a try another time, or at least get frustrated with the whole idea of websites.

Cta image

The answer to these problem is a Progressive Web App, available for both Android and iOS users. They offer some truly remarkable possibilities, but in my opinion, the biggest of those is their capacity for offline support. Your app can work just like a native app, provided you “install” it, that is visit the website first before going offline. The result? Once you download the PWA to your device, you can consume content within the app without an Internet connection. Vue.js App ring any bells?

So, let’s elaborate a little on this awesome feature in terms of static data storage for PWAs. I’ll outline how to introduce such support to an already existing application. Why? Well, wouldn’t you like to help your users never see the downasaur again?

How to introduce static data storage for PWAs to an app

Static Files Guide

The first step to entering the offline support phase is saving the app in browser storage. To do it, you need to store the static files necessary to run your application. How, you ask? Let me guide you through the steps.

  1. Think about those parts of the application that you want to have available without a network connection. Limit yourself to the essentials—it’ll make the whole process easier and faster.
  2. Configure your build engine to separate these parts into different chunks. It might be hard to split the logic inside your app to get the chunks working separately. This may be resolved by saving the whole app, providing you have sufficient browser storage space. You can check how much space you have in different environments.
  3. Note that you will probably have to store some dynamic data, too. So, it's well worth spending some time on dividing data into smaller chunks and saving the browser quota for further usage.
  4. When you already split and select a few or all of the static files from your app, it's time to prepare the config file for SW. You can write this file yourself, manually, or automatically by using some existing package, such as sw-precache.
  5. Remember to import this file into your website and run the script. I'm sure you know how to do it in your project.

Saving Time

Adding files to the cache is possible once the SW installevent is launched. Actually, SW doesn’t put a static file in the cache, but rather a response to the specified request.

That's why in the context of caching, I’m going to use the terms responses and files interchangeably. First, you need to declare these requests and include the absolute path to the files you want to store. If you want to write it manually, the code will look something like this:


self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open('your-cache-name').then(function(cache) {
      return cache.addAll([
        // your list of cache keys to store in cache
        'main.bundle.js',
        'vendor.bundle.js',
        // etc.
      ])
    })
  );
});

Let me explain what the code stands for:

  • I added a listener for the installevent fired on our self(window) object.
  • Then, I forced this event to wait until my instructions would be done via the event.waitUntilfunction. That is crucial, because the code opening and saving your files in the cache uses asynchronous methods.
  • To open a store inside the cache, run a function on the caches object called simply open, with a defined parameter of your store’s name your-cache-name This will return the object with your cache's store where you can add files (paths for requests) using the method cache.addAll, with an array of paths to the files as a parameter.

If you already have the SW file, you need to run it. There is a simple script to register the SW only in the browser or a device that supports navigation.serviceWorker:


if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js')
    .catch(function(err) {
      console.info('Service workers are not supported.');
    });
}

The Update Process

The troublesome part of the cached data is defining when it should be updated. Although there are a handful of ways to handle this problem that you can find in books and articles, I’ll just go ahead and remove one hurdle for you by showing you an actually working solution. It’s inspired by sw-precache library and serves to cache static files by the SW.

The actions of adding and removing cache will be separated from events delivered by the SW: install and activate. It can’t happen simultaneously because as long as the new SW is inactive, the old data from the cache will still be used by the browser. Interrupting it would result in crashing your app.

However, when the activate event is fired, you new data is saved in the cache and a new SW is launched. Now, it’s time to throw away everything you no longer need and clean up your cache.

The solution requires files to have checksums/hashes/any other value in the filename to differentiate individual file versions. If your files are missing this value, modify your builder. In webpack, you can add [chunkhash] to the filename in output, in Angular 6+ you can do it by adding the outputHashingparam to the configuration in angular.json.

Later on, this value will be the only thing to distinguish between the old and new SWs. The new SW will be installed and activated only when the SW file is different. If you already have the files well prepared, you can get down to writing the SW code. Create a file called sw-template.js which will later be used to generate the final SW file.

Let’s go back to the install event and add these lines of code to the template:


self.addEventListener('install', event => {
  event.waitUntil(
    caches.open($CACHE_STORE)
      .then(cache => {
        return cache.addAll($FILES);
      })
      .then(() => {
        return self.skipWaiting();
      })
  );
});

Have you noticed any changes?

Now, I’m no longer using static strings to declare the name of the cache store and the list of files. You see $CACHE_STORE and $FILES variables instead.

It's because you’re going to declare them in a later step of generating the SW file for your app. Moreover, there’s one function called at the end: self.skipWaiting(). It's needed to force the SW to change the stage from installation to activation. So, as you can see, the SW won't go forward until all of files are saved in the cache.

In some situations, your app will require all the files to work. In others, however, only a few of your files will be crucial for the app, leaving others to be loaded in the background. How to do that? Simply by splitting your $FILES array into two different arrays, i.e. $FILES_IMPORTANT and $FILES_ADDITIONAL. Furthermore, you should apply slight changes to a block of storing files in cache:


.then(function (cache) {
  cache.addAll($FILES_ADDITIONAL);
  return cache.addAll($FILES_IMPORTANT);
}) 
The additional files won't block SW going to the next stage and you can now activate event.

    self.addEventListener('activate', event => {
      event.waitUntil(
        caches.open($CACHE_STORE)
          .then(cache => {
            return cache.keys()
              .then(cacheNames => {
                return Promise.all(
                  cacheNames.filter(cacheName => {
                    return $FILES.indexOf(cacheName) === -1;
                  }).map(cacheName => {
                    return caches.delete(cacheName);
                  })
                );
              })
              .then(() => {
                return self.clients.claim();
              });
          })
      );
    }); 

As I mentioned before, the best place to clean your cache is listener for the event activate. Let's have a look at the process. You already know the event.waitUntil and the caches.open($CACHE_STORE) functions. cache.keys returns all keys of resources stored in the cache. So now you can filter this list by getting only the items from outside of your array of files and then delete them by cache.delete function. Last instruction here is fired to force all clients to use new SW immediately. Otherwise, it won't happen until the next load.

Fetch

You’re already prepared to store everything you need in the browser cache. But is the browser intelligent enough to see through cache and omit sending requests? Not really. That's why the SW delivers a hook for another event called fetch. It’s fired whenever the browser sends a request and is used to respond to requests with your stored files.


self.addEventListener('fetch', event => {
  if (event.request.method === 'GET') {
    let url = event.request.url.indexOf(self.location.origin) !== -1 ?
      event.request.url.split(`${self.location.origin}/`)[1] :
      event.request.url;
    let isFileCached = $FILES.indexOf(url) !== -1;

    if (isFileCached) {
      event.respondWith(
        caches.open($CACHE_STORE)
          .then(cache => {
            return cache.match(url)
              .then(response => {
                if (response) {
                  return response;
                }
                throw Error('There is not response for such request', url);
              });
          })
          .catch(error => {
            return fetch(event.request);
          })
      );
    }
  }
});

This time, the code is a bit longer. Let’s have a closer look at what happens:

  • The first line is for listening for fetch event. We want to respond only to GET requests, so there is an if instruction.
  • The url variable is declared. If the request asks for a resource from the origin, then you should change the absolute URL to a relative URL. It will tell you whether this should be saved in the cache.
  • If the isFiledCached flag returns true, you can call event.respondWith()Thanks to this method, you will be able to customize the action of responding to a request.
  • As a param, you need to put Promise which will return a response. To get this response from the cache, open the cache store caches.open($CACHE_STORE)and find the proper file by using the cache.match(url)method, where url is your modified (or not) URL from the request.
  • If the file is where it should be, you can return it as your response. If for some reason the file is not there (so method cache.match(url)returns a null value), you need to throw an error. To handle every error to appear from the moment you open the cache, you need to catch them using the catch ()method from Promise and then simply leave them to the browser by calling the fetch(event.request)

This is one of many strategies for serving resources from the cache. It’s called "Cache falling back to the network" in the offline cookbook. Such an approach offers high performance while loading the application because usually a response from the browser cache is faster than one from a server.

Navigation Rules

The browser cache is available without an Internet connection, and you have your static files stored in the browser cache with the logic to update and fetch them when needed. Looks like you’re prepared to support your app in offline mode. But wait! You still need to prepare redirect rules for SPA, so whenever a user wants to navigate to /app/page you need to tell the browser “do not load /app/page.html, load /index.html.” Let’s put a few more lines into the handler of the fetchevent.


self.addEventListener('fetch', event => {
    ...
    let isFileCached = $FILES.indexOf(url) !== -1;

    // If the request wasn't found in the array of files and this request has navigation mode and there is defined navigation fallback
    // then navigation fallback url is picked instead of real request url
    // and is checked if this url should be in the cache
    if (!isFileCached && event.request.mode === 'navigate' && $NAVIGATION_FALLBACK) {
      url = $NAVIGATION_FALLBACK;
      isFileCached = $FILES.indexOf(url) !== -1;
    }

    if (isFileCached) {
    ...
});

Just between the declaration of isFileCached and the if statement, I put another if statement for redirection. This instruction will be fired if the request isn't found in the array of files, the request has a navigation mode, and you have declared the $NAVIGATION_FALLBACK constant variable before. This condition is important here because it’s necessary in the situation where you want to redirect to a proper route.

If every condition is fulfilled then the browser should be redirected to our $NAVIGATION_FALLBACK That's why I set url to the navigation fallback value and then checked whether it should be included in the cache. If everything goes properly, then our SPA should open even without an active Internet connection. Ta-dam!

Generate

Alright, so I prepared a kind of template for caching static files by the SW. If your values are constant then you can just input proper values to the variables: $FILES (or $FILES_IMPORTANT and $FILES_ADDITIONAL), $CACHE_STORE, and $NAVIGATION_FALLBACK, and change them manually when you want to update the SW. Otherwise, you can generate values for these variables based on your built application. Actually $CACHE_STORE and $NAVIGATION_FALLBACK will be constant, but to keep it in one place we will generate them inside the node.js script. Here you are:

The first lines are for the definitions of the libraries you want to use. Further down, you have some methods:

  • readArgv() to read flags from the command line,
  • removeFile(path)to remove a file in the specific path,
  • isDirectory(path) to check whether a file in the specific path is a directory,
  • getFiles(source) to get all the files in the source without directories,
  • arrayToString(array) to convert array to a readable string for the js file,
  • generateSWFile() to generate the SW file with all the data.

The most important method here is, obviously, generateSWFile(), so I will briefly describe what it’s all about. First, you need to save all the flags to an argv constant variable. Then, remove the old SW file if it exists. The next step involves saving the template to the template constant variable, getting all the internal files and declaring an array of URLs to external files which you want to store in the browser cache. When you have both arrays, you can convert them to strings looking like a readable array for a js file ('[file1, file2, ..., fileN]'). Now, it's time to prepare all the content for the SW file, first the variables and then the template. Finally, you can write the file, save it, and you’re ready to go.

Offline Support For PWAs - opening internal and external links

Let’s Go Offline

Creating a basic SW file to store static resources in the browser cache is only the first step of adding offline support to your application. You can extend this solution by introducing new features or modifying existing ones. In case of any doubts, feel free to use my code available in the repository.

Remember that solutions presented herein don’t provide you with a ready library to store static files. If you want to have one, I’d recommend using the more advanced sw-precache.

The next step along the way to achieving full offline support is, well, quite a broad subject, and includes handling dynamic data without an Internet connection. To see how to store, update, and synchronize data between your application and a server, along with with some use cases and solutions, read the second part of my article about PWA going offline. 

Cta image

Mateusz Adamczyk avatar
Mateusz Adamczyk