Skip to content

Illustration by unDraw

Offline pages in Progressive Web Apps

A step-by-step guide on how to store and list offline available pages using Service Workers and the Cache API on the web.

This is a feature that I rarely come across when browsing the web and surely you do too. This might be for a number of reasons including app structure, lack of resources/time, and businesses being unaware that this can be accomplished on the web.

Implementing such a feature requires a Service Worker and effective use of the Cache API. Due to these requirements - especially the first one, there is a lot of friction for developers to develop such an offline experience.

Luckily, and strangely enough, it is not as difficult and time-consuming as I originally thought.

In this article, I will be showing you exactly how to implement this yourself. However, due to some complexities, you will have to adapt the code to your project accordingly.

For this article, I am taking into consideration that:

Tip: Although I do recommend that you are familiar first with the Service Worker API and the service worker’s lifecycle, it is far more convenient and fast to use Workbox, which is a well-tested library adding simplicity to your code.
Warning: This is not a copy-paste tutorial, nor it is for beginners. The examples make heavy use of asynchronous JavaScript with Promises and the async await ES6 syntax and you will most probably need to adapt the code to fit your own architecture.

Offline-Available Pages#

Offline page indicating the device is offline and lists the offline available pages that can be accessed offline
Page source: https://www.pwa.recipes/offline.html

This is one of the most advanced PWA features that I know of and still I believe that if you have a structure that allows it, you could implement this in 30 minutes tops!

Though, the hardest part is to craft the app’s structure in such a way that will allow you to add this functionality on top of it. Connecting the dots will be much more straightforward later on.

Nice-to-have's in your app structure:

  1. Each page has its own ID that matches its route (e.g. ID = about-me, Route = /about-me)
  2. Being able to extract the title of the page from its ID and/or route (e.g. about-me becomes About me)
  3. A cache dedicated to storing only these pages and their resources (e.g. cache name = offline-stored-pages)
Note: The above structure allows for the easiest implementation of offline pages but it may not be the most scalable approach.

Cool! Let’s get cracking…

A simpler example#

Let’s say we have a website that lists a number of products and each of the products has a unique slug/route. Ideally, we would like to display which product pages have been visited, thus cached and available offline, and hide the ones that are not.

In order to add this functionality, first and foremost we will have to make sure that we are 'listening' to a route inside our service worker’s JavaScript file (i.e. service-worker.js).

We then tell Workbox to cache all resources matching a product’s page URL inside a cache that we have created:

service-worker.js
const productPagesCacheName = 'runtime-product-pages-resources';

workbox.routing.registerRoute(
  /^\/product\//,
  new workbox.strategies.CacheFirst({
    cacheName: productPagesCacheName
  })
);

Then, inside of our app’s JavaScript files (i.e. app.js), we will include a function that checks within the cache runtime-product-pages-resources, created above using Workbox.

This function will search for any resources for each of the product’s routes inside this cache using each product’s URL (href of the <a> tag).

app.js
const productPagesCacheName = 'runtime-product-pages-resources';

const hideOfflineUnavailableProducts = () => {
  const products = document.querySelectorAll('a.product-page-link');
  
  caches.open(productPagesCacheName).then(productsRuntimeCache => {
    products.forEach(product => {
      productsRuntimeCache.keys().then(keys => {
        const productPage = keys.find(key => key.url === product.href);
        
        if (!productPage) {
          // reduce opacity of unavailable product pages
          product.classList.add('unavailable-offline');
        } else {
          // add the page to a list of offline available pages
        }
        
      });
    });
  });
}
Note that depending on your app structure the logic will be different. You may need to check if there is explicitly an HTML file inside the cache (and any other dependencies) in order to mark it as available offline and unavailable otherwise.

A more complex example#

Here is another more robust but also more complex example. Here, you don’t need to add a custom cache name in Workbox (though you still need to register a route to cache any resources):

app.js
const getCachedResourcesForPage = page => {
    return new Promise(async (resolve, reject) => {
        // Get a list of all of the caches for this origin
        const pageCaches = await getPageCaches(page);

        const results = await Promise.all(pageCaches.map(async pageCache => {
            const cachedResources = await pageCache.keys();
            
            // Check for any html files inside each cache under item's name
            for (const request of cachedResources) {
                if (request.url.match(`(${page}.+\.html)|${page}$`)) {
                    // return the cached page and exit
                    return request;
                }
            }

            return false;
        }));
        const cachedRequest = results.find(request => !!request);
        resolve(cachedRequest);
    });
};
app.js
const getPageCaches = page => {
    return new Promise(async (resolve, reject) => {
        // Get a list of all of the caches for this origin
        const pageCacheNames = await caches.keys().then(keys => keys.filter(key => key.match(page)));

        const promises = pageCacheNames.map(async cacheName => await caches.open(cacheName));

        const results = await Promise.all(promises); 
        resolve(results);
    });
};
app.js
const hideOfflineUnavailableProducts = () => {
  const products = Array.from(document.querySelectorAll('a.product-page-link'));
  
  products.map(async product => {
    // examples of ways to store and get the ID of a product/page
    const productID = product.href || product.getAttribute('data-id') || product.id;
    const cachedRequest = getCachedResourcesForPage(productID);
    
    if (cachedRequest) {
      // product page is available offline
    } else {
      // product not available, mark it as unavailable
    }
  });
};

Your can include your logic in the hideOfflineUnavailableProducts function above, where you can mark the link as available offline or not otherwise. I trust you are better at naming functions than me! 😆

The you can listen for the window's offline and load events to call the hideOfflineUnavailableProducts function declared above:

app.js
window.addEventListener('offline', hideOfflineUnavailableProducts);

window.addEventListener('load', () => {   
  if (!navigator.onLine) {
    hideOfflineUnavailableProducts();
  }
});

Finally, depending on your use-case for both examples above (simpler and more complex), we can add some styling for the offline unavailable product links:

styles.css
.unavailable-offline {
  opacity: 0.25; 
  pointer-events: none; 
}
Try it yourself at www.pwa.recipes by going offline after visiting a few pages.

Further Reading#

Cheers! Have a good day!