Making a web application progressive


This tutorial will guide you through making a web app progressive.

What are progressive web apps?

The KISS(Keep It Simple, Short) definition for progressive web apps PWAs is that they are web apps that can live offline. Naturally web apps operate over a network and they don’t do much when you are off that network. PWAs provide offline capabilities so that they appear to users like traditional apps or native mobile apps.

Getting started

In this tutorial we shall build a currency converter web app that consumes the Free Currency Converter API then work on making it progressive. We shall use a service worker to do the heavy lifting in the background and cache offline resources and indexedDB to persist data on the client side.

We shall also leverage a couple of frameworks and libraries to abstract a lot of the heavy lifting and to get the ball rolling quickly. These include:

  • Materialize: a front end responsive CSS framework.
  • jQuery: a Javascript library to handle DOM manipulation and event handling.
  • IndexedDB Promised to add promises to IndexedDB.

Setting Up

The full source code for this application can be found here

Implement the UI

We shall implement our simple UI in index.html. A couple of things to note:

  • Meta tags and CSS resources in the head.
  • Javascript resources at the end of the body for optimized loading.
  • Simple UI encapsulated as a card

To save time we won’t elaborate on the CSS style sheets but there are couple of rules in there that tweak the UI so that we have a decent looking app.

Writing the Javascript

Most of the functionality for our currency converter web app shall be implemented in Javascript ES6. We shall encapsulate the functionality in a CurrencyConverter class found in currency-converter.js.  When the page is loaded we instantiate our class:

$(document).ready(() => new CurrencyConverter());

CurrencyConverter

This ES6 class serves as the backbone for the web app. There are a couple of methods that we will shall have a closer look at:

registerServiceWorker

In this method we register our service worker which shall do a lot of the heavy lifting that makes our web app progressive. We do a quick check to make sure that the current browser supports the service worker API:

if (!navigator.serviceWorker) return;

Then we call the register method on the serviceWorker object which returns a promise passing a service worker registration object which we use to query the state of the service worker. At this point it would help to read Jake Archibald’s article on the service worker life cycle.

navigator.serviceWorker.register('/sw.js').then(reg => { 
  if (!navigator.serviceWorker.controller) return;

  if (reg.waiting) { 
    this.updateReady(reg.waiting); 
    return; 
  }
  if (reg.installing) { 
    this.trackInstalling(reg.installing); 
    return; 
  }
  reg.addEventListener('updatefound', () => this.trackInstalling(reg.installing));
});

Sometimes there is already a running service worker or we have updated our service worker and we need it to take over. In such a case the service worker would be in a waiting or an installing state. We shall display a toast to give the user the option to force update the service worker. We use the Materialize Toast library to display our toasts.

updateReady(worker) {
  const toast = M.toast({
    html: `New version available.Refresh`,
    displayLength: 1000 * 1000 * 1000
  });

  $('.toast-update').click(event => {
    event.preventDefault();
    worker.postMessage({action: 'skipWaiting'});
  });
}

initInstallPrompt

One of the nifty features about PWAs is that they can be installed on the user’s mobile device or desktop just like native apps. To do this a web app manifest must be defined which tells the device how the PWA should behave when it is being installed. The web app manifest is defined in the manifest.json file which is found in the root of the app and it looks something like this:

{
 "short_name": "Currency Converter",
 "name": "Currency Converter",
 "icons": [
   {
     "src": "/images/icon-512.png",
     "type": "image/png",
     "sizes": "512x512"
   }
 ],
 "start_url": "/",
 "background_color": "#0000FF",
 "display": "standalone",
 "scope": "/",
 "theme_color": "#0000FF"
}

You can find an awesome article on the web app manifest by the experts at Google over here.

displayOfflineToast

Before we get into our service worker, lets look at how we let the user know that they are offline and that we are serving offline content.

 if (!navigator.onLine){
    this.displayOfflineToast();
 }
 window.addEventListener('offline', () => {
    this.displayOfflineToast();
 });
 window.addEventListener('online', () => {
    this.dismissToast('.toast');
 });

navigator.onLine is a nifty feature implemented by most browsers that allows us to check whether we are online or not. We also hook into the offline and online events so that we can display offline toasts for those instances where a user goes offline during their current session.

sw.js

In the service worker we first cache all resources that we know won’t be changing unless we are doing an update. So we cache our scripts and style sheets as well as static resources like images.

const staticCacheName = 'currency-converter-v1';

self.addEventListener('install', event => {
	event.waitUntil(
		caches.open(staticCacheName).then(cache => {
			return cache.addAll([
				'/',
				'js/jquery.min.js',
				'js/idb.js',
				'js/materialize.min.js',
				'js/currency-converter.js',
				'https://fonts.googleapis.com/icon?family=Material+Icons',
				'css/materialize.min.css',
				'css/currency-converter.css',
				'images/cactus.png'
			]);
		})
	);
});
self.addEventListener('activate', event => {
	event.waitUntil(
		caches.keys().then(cacheNames => Promise.all(
			cacheNames.filter(cacheName => cacheName !== staticCacheName).map(cacheName => caches.delete(cacheName))
			)
		)
	);
});

Note that our cache is named with a version number so that when we want to update our cache we simply increment the version number, the old cache is deleted and all future requests are passed through the new cache. That clean up logic is done during the activate event.

self.addEventListener('activate', event => {
	event.waitUntil(
		caches.keys().then(cacheNames => Promise.all(
			cacheNames.filter(cacheName => cacheName !== staticCacheName).map(cacheName => caches.delete(cacheName))
			)
		)
	);
});

We also add this snippet to override any existing service worker when we are doing an update.

self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

We then hook into the fetch event to intercept all network requests and return responses from the cache whenever we have a response.

self.addEventListener('fetch', event => {
	const requestUrl = new URL(event.request.url);
	if (requestUrl.pathname.startsWith('/api/v5/convert')) {
		event.respondWith(serveRate(event.request));
		return;
	}

	event.respondWith(
		caches.match(event.request).then(response => {
			return response || fetch(event.request);
		})
	);
});

We use serveRate to fetch the currency rate request from the cache but also to keep it to date.

Finally we hook into the message event so we can trigger the skipWaiting action when we want to install a new service worker

self.addEventListener('message', event => {
	if (event.data.action === 'skipWaiting') {
		self.skipWaiting();
	}
});

And that’s it a progressive currency converter.