This case study explores how I added the capability for a website to work offline using Grav, a PHP-based CMS for developers. I achieved this by introducing a set of technologies known as Progressive Web Apps, specifically Service Workers and the Cache API.

First Approach: Cache-First

Initially, I decided to use a cache-first approach. In this approach, when a fetch request is intercepted by the Service Worker, it first checks if the requested data is already cached. If not, it fetches it from the network. This approach improves the website’s loading speed for pages that are already cached, even when the user is online but connected to a slow network. However, it does introduce some complexity in managing cache updates when new content is shipped.

To implement this approach, I followed these steps:

  1. I introduced a service worker and loaded it as part of the website’s JavaScript scripts.
  2. When installing the service worker, I cached the site’s “skeleton”, which is a basic set of HTML, CSS, and JS that is always available and shown to users, even when offline.
  3. I intercepted requests for additional links and cached them.

Introducing a Service Worker

I added the service worker in a sw.js file in the site’s root. This allowed it to work on all site subfolders and the homepage as well. The service worker initially only logged any network request:

self.addEventListener('fetch', (event) => {
 console.log(event.request);
});

Next, I registered the service worker by including a script in every page:

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

 navigator.serviceWorker.register('/sw.js', {
   scope: '/'
 }).then(() => {
   //...ok
 }).catch((err) => {
   console.log('registration failed', err);
 });
});

After registering the service worker, the website should function correctly upon page refresh.

At this point, I needed to serve only the app shell, which is a basic HTML, CSS, and JS layout that is always cached and shown to users, even when offline. The first time a user loads the site, the normal version (full-HTML version) is shown, and the service worker is installed. From then on, whenever a page is loaded, the shell is loaded first, followed by a stripped-down version of the page’s content without the shell.

I listened for the “install” event, which fires when the service worker is installed or updated. During this event, I initialized the cache with the content of the shell, which includes the basic HTML layout, CSS, JS, and some external assets:

const cacheName = 'writesoftware-v1';

self.addEventListener('install', (event) => {
 event.waitUntil(caches.open(cacheName).then(cache => cache.addAll([
 '/shell',
 'user/themes/writesoftware/favicon.ico',
 'user/themes/writesoftware/css/style.css',
 'user/themes/writesoftware/js/script.js',
 'https://fonts.googleapis.com/css?family=Press+Start+2P',
 'https://fonts.googleapis.com/css?family=Inconsolata:400,700',
 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/themes/prism.min.css',
 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/prism.min.js',
 'https://cdn.jsdelivr.net/prism/1.6.0/components/prism-jsx.min.js'
 ])));
});

In the “fetch” event, I intercepted requests going to additional links and fetched the shell from the cache instead of the network. If the requested URL belonged to Google Analytics or ConvertKit, I bypassed the cache and fetched the resources directly:

self.addEventListener('fetch', (event) => {
 const requestUrl = new URL(event.request.url);

 if (requestUrl.href.startsWith('https://www.googletagmanager.com') ||
   requestUrl.href.startsWith('https://www.google-analytics.com') ||
   requestUrl.href.startsWith('https://assets.convertkit.com')) {
   event.respondWith(fetch(event.request.url, { mode: 'no-cors' }));
   return;
 }

 event.respondWith(caches.match(event.request)
   .then((response) => {
     if (response) { return response; }
     if (requestUrl.origin === location.origin) {
       if (requestUrl.pathname.endsWith('?partial=true')) {
         return fetch(requestUrl.pathname);
       } else {
         return caches.match('/shell');
       }
     }
     return fetch(event.request.url);
   })
   .then(response => caches.open(cacheName).then((cache) => {
     cache.put(event.request.url, response.clone());
     return response;
   }))
   .catch((error) => {
     console.error(error);
   }));
});

To handle navigating through the website, I modified the script.js file to intercept link clicks and fetch the corresponding partial page:

window.addEventListener('load', () => {
 //...

 window.onclick = (e) => {
   let node = e.target;
   while (node !== undefined && node !== null && node.localName !== 'a') {
     node = node.parentNode;
   }
   if (node !== undefined && node !== null) {
     channel.postMessage({
       task: 'fetchPartial',
       url: `${node.href}?partial=true`
     });
     return false;
   }
   return true;
 }
});

On the service worker side, I connected to the ws_navigation channel and listened for the fetchPartial message. If the requested URL was cached, I sent the cached response back as a message to the page. If it wasn’t cached, I fetched it, sent it back as a message to the page, and cached it for future visits:

const channel = new BroadcastChannel('ws_navigation');
channel.onmessage = (event) => {
 if (event.data.task === 'fetchPartial') {
   caches
     .match(event.data.url)
     .then((response) => {
       if (response) {
         response.text().then((body) => {
           channel.postMessage({ url: event.data.url, content: body });
         });
         return;
       }

       fetch(event.data.url).then((fetchResponse) => {
         const fetchResponseClone = fetchResponse.clone();
         fetchResponse.text().then((body) => {
           channel.postMessage({ url: event.data.url, content: body });
         });

         caches.open(cacheName).then((cache) => {
           cache.put(event.data.url, fetchResponseClone);
         });
       });
     })
     .catch((error) => {
       console.error(error);
     });
 }
}

With these modifications, the website was fully functional even when offline. This approach also served as a progressive enhancement, meaning that older browsers and browsers that don’t support service workers would still work normally.

Second Approach: Network-First, Drop the App Shell

In the second approach, I opted for a network-first strategy with the goal of reducing the reliance on caching and the complexity of managing partial updates. With this approach, when a user loads a page, it is fetched from the network first. If the network call fails, I check if the page is cached. If it is, I retrieve it from the cache. Otherwise, I show users a GIF indicating that they are offline or that the page doesn’t exist.

Additionally, in this approach, I decided to drop the app shell altogether since I didn’t plan on creating an installable app at that time.

To implement the network-first approach, I made the following changes:

In script.js, I modified the code to only register the service worker and fetch the initial page:

const OFFLINE_GIF = '/user/themes/writesoftware/img/offline.gif';

const fetchPartial = (url) => {
 fetch(`${url}?partial=true`)
 .then((response) => {
   response.text().then((body) => {
     if (document.getElementById('content-wrapper')) {
       document.getElementById('content-wrapper').innerHTML = body;
       if (document.getElementById('browser-page-title')) {
         document.title = document.getElementById('browser-page-title').innerHTML;
       }
       history.pushState(null, null, url);
       gtag('config', 'UA-XXXXXX-XX', { page_path: url });
       Prism.highlightAll();
     }
   });
 })
 .catch(() => {
   if (document.getElementById('content-wrapper')) {
     document.getElementById('content-wrapper').innerHTML = `<center><h2>Offline</h2><img src="${OFFLINE_GIF}" /></center>`;
   }
 });
}

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

 navigator.serviceWorker.register('/sw.js', {
   scope: '/'
 }).then(() => {
   fetchPartial(window.location.pathname);
 }).catch((err) => {
   console.log('SW registration failed', err);
 });

 window.onclick = (e) => {
   let node = e.target;
   while (node !== undefined && node !== null && node.localName !== 'a') {
     node = node.parentNode;
   }
   if (node !== undefined && node !== null) {
     fetchPartial(node.href);
     return false;
   }
   return true;
 }
});

In sw.js, I modified the code to fetch the requested page from the network first. If the response status is 404, indicating the page doesn’t exist, I fetch a GIF image instead. For any successful fetch, I cache the response:

const CACHE_NAME = 'writesoftware-v1';
const OFFLINE_GIF = '/user/themes/writesoftware/img/offline.gif';
const PAGENOTFOUND_GIF = '/user/themes/writesoftware/img/pagenotfound.gif';

self.addEventListener('install', (event) => {
 event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll([
 '/user/themes/writesoftware/favicon.ico',
 '/user/themes/writesoftware/css/style.css',
 '/user/themes/writesoftware/js/script.js',
 '/user/themes/writesoftware/img/offline.gif',
 '/user/themes/writesoftware/img/pagenotfound.gif',
 'https://fonts.googleapis.com/css?family=Press+Start+2P',
 'https://fonts.googleapis.com/css?family=Inconsolata:400,700',
 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/themes/prism.min.css',
 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/prism.min.js',
 'https://cdn.jsdelivr.net/prism/1.6.0/components/prism-jsx.min.js'
 ])));
});

self.addEventListener('fetch', (event) => {
 if (event.request.method !== 'GET') return;
 if (event.request.headers.get('accept').indexOf('text/html') === -1) return;

 const requestUrl = new URL(event.request.url);
 let options = {};

 if (requestUrl.href.startsWith('https://www.googletagmanager.com') ||
   requestUrl.href.startsWith('https://www.google-analytics.com') ||
   requestUrl.href.startsWith('https://assets.convertkit.com')) {
   options = { mode: 'no-cors' };
 }

 event.respondWith(fetch(event.request, options)
   .then((response) => {
     if (response.status === 404) {
       return fetch(PAGENOTFOUND_GIF);
     }
     const resClone = response.clone();
     return caches.open(CACHE_NAME).then((cache) => {
       cache.put(event.request.url, response);
       return resClone;
     });
   })
   .catch(() => caches.open(CACHE_NAME).then(cache => cache.match(event.request.url)
   .then((response) => {
     return response || fetch(OFFLINE_GIF);
   })
   .catch(() => fetch(OFFLINE_GIF)))));
});

This approach simplified the implementation by reducing the caching requirements and removing the partial updates. However, this approach may not be viable for all scenarios and may not provide the same level of offline functionality as the cache-first approach.

Going Simpler: No Partials

In this experiment, I removed the code responsible for intercepting link clicks and fetching partials. Instead, I relied solely on Service Workers and the Cache API to deliver the plain pages of the site without managing partial updates.

To implement this approach, I made the following changes:

In script.js, the code was simplified to only register the service worker:

window.addEventListener('load', () => {
 if (!navigator.serviceWorker) { return; }
 navigator.serviceWorker.register('/sw.js', {
   scope: '/'
 }).catch((err) => {
   console.log('SW registration failed', err);
 });
});

In sw.js, I modified the code to fetch the requested page directly from the network. If the response is not available, I fetch a GIF image instead:

const CACHE_NAME = 'writesoftware-v1';
const OFFLINE_GIF = '/user/themes/writesoftware/img/offline.gif';
const PAGENOTFOUND_GIF = '/user/themes/writesoftware/img/pagenotfound.gif';

self.addEventListener('install', (event) => {
 event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll([
 '/user/themes/writesoftware/favicon.ico',
 '/user/themes/writesoftware/css/style.css',
 '/user/themes/writesoftware/js/script.js',
 '/user/themes/writesoftware/img/offline.gif',
 '/user/themes/writesoftware/img/pagenotfound.gif',
 'https://fonts.googleapis.com/css?family=Press+Start+2P',
 'https://fonts.googleapis.com/css?family=Inconsolata:400,700',
 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/themes/prism.min.css',
 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.6.0/prism.min.js',
 'https://cdn.jsdelivr.net/prism/1.6.0/components/prism-jsx.min.js'
 ])));
});

self.addEventListener('fetch', (event) => {
 if (event.request.method !== 'GET') return;
 if (event.request.headers.get('accept').indexOf('text/html') === -1) return;

 const requestUrl = new URL(event.request.url);
 let options = {};

 if (requestUrl.href.startsWith('https://www.googletagmanager.com') ||
   requestUrl.href.startsWith('https://www.google-analytics.com') ||
   requestUrl.href.startsWith('https://assets.convertkit.com')) {
   options = { mode: 'no-cors' };
 }

 event.respondWith(fetch(event.request, options)
   .then((response) => {
     if (response.status === 404) {
       return fetch(PAGENOTFOUND_GIF);
     }
     const resClone = response.clone();
     return caches.open(CACHE_NAME).then((cache) => {
       cache.put(event.request.url, response);
       return resClone;
     });
   })
   .catch(() => caches.open(CACHE_NAME).then(cache => cache.match(event.request.url)
   .then((response) => {
     return response || fetch(OFFLINE_GIF);
   })
   .catch(() => fetch(OFFLINE_GIF)))));
});

This simplified approach removes the need for partials and the associated code to handle link clicks. However, it may not provide as rich of an offline experience as the previous approaches.

Overall, these approaches demonstrate how to add offline capabilities to a website by utilizing Service Workers and the Cache API. The choice of approach depends on the specific requirements and goals of the website.