Advantages of Progressive Web Apps:

  • Reliable - Load instantly and never show the dinasaur.

  • Fast - Respond quickly to user interactions with silky smooth animations.

  • Enaging - Feel like a natural app on the device, with immersive user experience.

What is a Progressive Web App

  • Progressive - Works for every user, regardless of browser choice because it’s built with progressive enhancement as a core tenet.

  • Responsive - Fits any form factor: desktop, mobile, tablet, or whatever is next.

  • Connectively independent - Enhanced with service workers to work offline or on low-quality networks.

  • App-like - Feels like an app, because the app shell model seperates the application functionality from application content.

  • Fresh - Always up-to-date thanks to the service worker update process.

  • Safe - Served via HTTPS to prevent snooping and to ensure content hasn’t been tampered with.

  • Discoverable - Is identifiable as an ‘application’ thanks to W3C manifest and service worker registration scope, allowing search engines to find it.

  • Re-engagable - Makes Re-engagement easy through features like push notification.

  • Installable - Allows users to add apps they find most useful to their home screen without the hassle of an app store.

  • Linkable - Easily share the application via URL, doesn’t require complex installation.

What is App Shell

The app’s shell is the minimal HTML, CSS, JavaScript that is required to power the user interface of a progressive web app and is one of the components that ensures reliably good performance. Its first load should be extremely quick and immediately cached.

‘Chched’ means that the shell files are loaded once over the network and then saved to the local device. Every subsequent time that the user opens the app, the shell files are loaded from the local device’s cache, which results in blazing-fast startup times.

App shell architecture seperates the core application infrastructure and UI from the data. All of the UI and infrastructure is cached locally using a service worker so that on subsequent loads, the PWA only needs to retrieve the necessary data, instead of having to load everything.

A service worker is a script that your browser runs in the background, seperate from a web page, opening the door to features that don’t need a web page or user interaction.

The app shell is similar to the bundle of code that you’d publish to an app store when building a native app. It is the core components necessary to get your app off the ground, but likely doesn’t contain the data.

Using the app shell architecture allows you to focus on speed, giving the PWA similar properties to native apps:

  • instant loading

  • regular updates

Implement App Shell

Create the HTML for the App Shell

The Components consist of:

  • Header with a title, and app/refresh button

  • Container for forecast cards

  • A forecast card template

  • A dialog for adding new cities

  • A loading indicator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weather PWA</title>
<link rel="stylesheet" type="text/css" href="styles/inline.css">
</head>
<body>

<header class="header">
<h1 class="header__title">Weather PWA</h1>
<button id="butRefresh" class="headerButton"></button>
<button id="butAdd" class="headerButton"></button>
</header>

<main class="main">
<div class="card cardTemplate weather-forecase" hidden>
...
</div>
</main>

<div class="dialog-container">
...
</div>

<div class="loader">
<svg viewBox="0 0 32 32" with="32" height="32">
<circle id="spinner" cx="16" cy="16" r="14" fill="none"></circle>
</svg>
</div>

<!-- insert link to app.js here -->
</body>
</html>

Notice the loader is visible by default. This ensures that the user sees the loader immediately as the page loads, giving them a clear indication that the content is loading.

Start with a fast load

Differentiating the first run

User preferences, like the list of cities a user has subscribed to, should be stored locally using IndexedBD or another fast storage mechanism. To simplify this code, here we use localStorage, which is not ideal for production apps because it is a blocking, synchronous storage mechanism that is potentially very slow on some device.

1
2
3
4
5
// Save list of cities to lcoalStorage
app.saveSelectedCities = function () {
var selectedCities = JSON.stringify(app.selectedCities)
localStorage.selectedCities = selectedCities
}

Next, let’s add the startup code to check if the user has any saved cities and render those

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.selectedCities = lcoalStorage.selectedCities
if (app.selectedCities) {
app.selectedCities = JSON.parse(app.selectedCities)
app.selectedCities.forEach(function (city) {
app.getForecast(city.key, city.label)
})
} else {
app.updateForecastCard(initialWeatherForecast)
app.selectedCities = [
{
key: initialWeatherForecast.key,
label: initialWeatherForecast.label,
},
app.saveSelectedCities()
]
}

Use service workers to pre-cache the App Shell

PWA has to be fast, and installable, which means that they work online, offline, and on intermittent, slow connections.

To achieve this, we need to cache our app shell using service worker, so that it’s always available quickly and reliably.

Features provided via service workers should be considered a progressive enhancement, and added only if supported by the browser.

Register the service worker if it’s available

The first step to making the app work offline is to register a service worker, a script that allows background functionality without the need of an open web page or user interaction.

This takes two simple steps:

  • Tell the browser to register the JavaScript file as the service worker

  • Create a JavaScript file containing the service worker

First, we need to check if the browser supports service worker, and if it does, register the service worker. Add the following code to app.js

1
2
3
4
5
6
7
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./service-worker.js')
.then(function () {
console.log('Service Worker Registered')
})
}

Cache the site assets

When the service worker is registered, an install event is triggered the first time the user visits the page.

In this event handler, we will cache all the assets that are needed for the application.

When the service worker is fired, it should open the caches object and populate it with the assets necessary to load the App Shell. Create a file called service-worker.js in your application root folder. This file must live in the application root because the scope for service worker is defined by the directory in which the file resides. Add this code to your new service-worker.js file.

1
2
3
4
5
6
7
8
9
10
11
12
var cacheName = 'weatherPWA'
var filesToCache = []

self.addEventListener('install', function (e) {
console.log('[ServiceWorker] Install')
e.waitUntil(
caches.open(cacheName).then(function (cache) {
console.log('[ServiceWorker] Caching app shell')
return cache.addAll(filesToCache)
})
)
})

First, we need to open the cache with caches.open() and provide a cache name. Providing a cache name allows us to version files, or separate data from the app shell so that we can easily update one but not affect other.

Once the cache is open, we can then call cache.addAll(), which takes a list of URLs, then fetches them from the server and adds the response to the cache. Unfortunately, cache.addAll() is atomic, if any of the files fail, the entire cache step fails.

DevTools can debug service workers. Before reloading your page, open up DevTools, go the Service Worker pane on the Application panel.

When you see a blank page like this, it means that the currently open page doesn’t have any registered service workers.

Now reload your page. The Service Worker pane should look like this.

When you see information like this, it means the page has a service worker running.

Let’s add some logic on the activate event listener to update the cache.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener('activate', function (e) {
console.log('[ServiceWorker] Activate')
e.waitUntil(
caches.keys().then(function (keyList) {
return Promise.all(keyList.map(function (key) {
if (key !== cacheName) {
console.log('[ServiceWorker] Removing old cache', key)
return caches.delete(key)
}
}))
})
)
return self.clients.claim()
})

This code ensures that your service worker updates its cache whenever any of the app shell files change. In order for this to work, you’d need to increment the cacheName variable at the top of your service worker file.

When the app is complete, self.clients.claim() fixes a corner case in which the app wasn’t returning the latest data. You can reproduce the corner case by commenting out the line below and then doing the following steps:

  • load app for first time so that the initial City data is shown

  • press the refresh button on the app

  • go offline

  • reload the app

You expect to see the newer data, but you actually see the initial data. This happens because the service worker is not yet activated. self.clients.claim() essentially lets you activate the service worker faster.

Finally, let’s update the list of files required for the app shell. In the array, we need to include all of the files our app needs, including images, js, css, etc.

1
2
3
4
5
6
7
8
var filesToCache = [
'/',
'/index.html',
'/scripts/app.js',
'/styles/inline.css',
'/images/clear.png',
// ...
]

Serve the app shell from the cache

Service workers provide the ability to intercept requests made from our PWA and handle them within the service worker. That means we can determine how we want to handle the request and potentially serve our own cached response.

1
2
3
4
5
6
7
8
self.addEventListener('fetch', function (e) {
console.log('[ServiceWorker] Fetch', e.request.url)
e.responseWith(
caches.match(e.request).then(function (response) {
return response || fetch(e.request)
})
)
})

Stepping from inside, out, caches.match() evaluates the web request that triggered the fetch event, and checks to see if it’s available in the cache. It then either responds with the cached version, or uses fetch to get a copy from the network. The response is passed back to the web page with e.responseWith()

Beware of the edge cases

This code must not be used in production because of the many unhandled edge cases

  • Cache depends on updating the cache key for every change

  • Requires everything to be redownloaded for every change

  • Browser cache may prevent the service worker cache from updating

  • Beware of cache-first strategies in production