This post is a complete guide to building a Progressive Web App (PWA) from the beginning using Google’s Workbox. By the end of this guide, you’ll be a real PWA developer!
If you haven’t already, check out my previous article on the fundamentals of Progressive Web Apps where we explored service workers and how they work, as well as lots of concepts.
This guide will take you through your own practical build where you’ll learn Workbox to complete a real PWA! I’m excited to take you through it. Let’s dive in!
Caching
A service worker is capable of caching files aggressively so that we do not need to request them, again unless they are updated. That is called pre-caching and it happens during the install lifecycle.
Service workers can also intercept fetch events and cache the resulting information. This is called runtime caching and it is natively implemented like this:
// --> sw.js
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
})
// So if there's a cached version available, use it,
// but fetch an update for next time.
return cachedResponse || fetchPromise;
}
)
);
});
Don’t worry if you don’t fully understand this code snippet, that’s exactly what you’re here to learn. We are going to use Workbox right from the beginning to cover everything you need to build a PWA!
What is Workbox?
Google’s Workbox is a set of libraries that simplifies the process of caching with service workers. We will use it to both implement pre-caching and runtime caching. The service worker is registered as normal in the main thread. But in the worker thread we can start using Workbox packages right away.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
Workbox handles runtime caching with what they call a service worker router. This naming makes total sense since we are intercepting URLs so we need to register routes for that. Again, don’t worry if you still cannot see the big picture. You are going to learn by coding.
To each route you need to provide a callback function for the service worker in order to tell it how to cache the request. There are many runtime caching strategies but most of the time we will only need these:
- Cache Only: the service worker forces a response from the cache and never from the network. You mostly will not want to use this strategy because if a match is not found in the cache the response will look like a connection error.
- Network Only: the service worker forces a response from the network and never from the cache. This is actually the default browsers behaviour so there will be very few cases when you want to use this strategy too.
- Cache First falling back to network: the service worker tries the cache first and if there is no cached response it goes to the network. But most importantly: the response from the network is cached before being passed to the browser.
- Network First falling back to cache: the service worker tries the network first. If the request is successful the response is cached before being passed to the browser. If the request fails it falls back to the last cached response.
- Stale While Revalidate: here we only use responses from the cache but we also make a call to the network in the background and if that call is successful we cache that response for the next time. This would be the most common strategy.
Now take another look at the previous code snippet. What strategy is it following? Take a couple of seconds to think about it…
…OK. Time is up! The snippet is implementing Stale While Revalidate natively. We will not need to do that. All these usual runtime caching strategies are pre-defined in the Workbox routing module.
Rick and Morty
Our practical training is going to consist of a simple app that displays a list of 20 characters from the Rick and Morty TV show.
This choice was made based on the fact that the Rick and Morty API does not need authentication which simplifies our job. Well… and also because the show is so cool.
To fulfil this little challenge you will need the help of this public repository.
The master
branch contains a naked project: the app without the service worker blanket. However all necessary packages are already specified and the infrastructure is ready for you to take off.
Each of those steps are zero-based numbered in the shape of branches. They keep a step-xx-title-of-the-step
naming convention.
The step 0 is a replica of master
. No code to be provided there. We will just use it to picture the specific goals. The next steps/branches do involve some development. They are your tasks.
Are you ready to start?
Step 0: Non Progressive App
So first things first. Please clone the repo.
And run:
npm i
git fetch --all
git checkout step-00-non-progressive-app
git checkout -b step-00-non-progressive-app-mine
By doing this you are first installing the dependencies and right next you are switching to the step-00-non-progressive-app
branch and then checking out a copy of it. That will be your start point.
And secondly:
npm run build
npm start
Open this URL in Google Chrome: http://localhost:1981/
.
You are probably looking at something like this:
If you open the console you will see that you are tracing every retrieved data. On the home page we are collecting 20 random characters. By clicking on one of them you navigate to the detail card where you can find out if the character is dead or alive in the TV show. And then of course you can go back to the list, which will probably look a little different because the items are getting shuffled.
Although this is not required, if you like take a look at the source code to have a better understanding of the project.
Go offline
Open up the Chrome DevTools and go offline. One way of doing this is marking the checkbox “Offline” in the Application section.
Tip: use cmd + shift + p for Mac or ctrl + shift + p for Windows and type “offline”.
Reload the page.
You should see this:
Play with it using the space bar. How much do you score in the offline Dino Game?
Anyway, as you can see we have lost everything. This exactly what we are trying to avoid by making a PWA.
Audit with Lighthouse
Lighthouse is an excellent tool to improve the quality of web pages. It has audits for performance, accessibility, progressive web apps, and more. It is pre-installed in all Chrome browsers and you can either run it from the DevTools or from a Node command.
In our case we are ready to run our npm script, generate the corresponding HTML report and open it up automatically in our browser.
Do not forget to go online again first!
Run this in a second terminal:
npm run lighthouse
As you can see we are scoring very high in everything but in the Progressive Web App part. Click on that PWA grey rounded icon and you will be scrolled down to see what is happening.
Notice that there are a lot of things in red:
-
Current page does not respond with a 200 when offline.
-
start_url
does not respond with a 200 when offline. -
Does not register a service worker that controls page and
start_url
. -
Web app manifest does not meet the installability requirements.
-
Does not redirect HTTP traffic to HTTPS.
-
Is not configured for a custom splash screen.
-
Does not set a theme color for the address bar.
-
Does not provide a valid
apple-touch-icon
.
The HTTPS red flag is totally expected. For security reasons service workers only run over the HTTPS protocol but if the hostname corresponds our localhost the HTTP protocol is also considered secure and we can run our service worker over it. This is intended to make development easier.
We assume that our app will run on a secure protocol in production so we can ignore this supposed failure. However we definitely need to work on the rest of them and make them into green.
Are you ready for the challenge?
From this point on you are going to start providing your own code.
Step 1: Web App Manifest
The first you need is to create a src/manifest.json
.
This file can also be commonly named
manifest.webmanifest
.
As mentioned in the previous article the manifest defines the parameters of our installation.
It looks like this:
{
"name": "Google Maps",
"short_name": "Maps",
"description": "Find your location with Google",
"icons": [
{
"src": "/images/icons-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/images/icons-512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/?launch=pwa",
"background_color": "#3367D6",
"display": "standalone",
"orientation": "landscape",
"scope": "/maps/",
"theme_color": "#3367D6"
}
For a detailed explanation on each property of the manifest, check out this post by Pete LePage and François Beaufort from the Chromium team.
Let’s focus on your manifest. It should:
-
Define both the short (
Rick & Morty
) and the long (Rick & Morty PWA
) name for the app. -
Only include the mandatory 192x192px and 512x512px icons. They are located in
src/assets/img/icons
. -
Define
/index.html
as the opened page when the app is first launched. -
Tell the browser you want your app to open in a standalone window.
-
Not be scoped. Either remove that property or leave it as
/
. -
Use the characteristic yellow from our app for the background color:
#fccf6c
. And since the theme color should match the color of the tool bar we will employ#004d40
.
And let’s have some fun while doing this. Go to the Web App Manifest Generator and introduce the corresponding values. Click on the “COPY” button.
Create a manifest.json
in the src
folder and paste the generated file contents.
But that is not all. We are still missing the icons. You can copy this right after the short_name
:
{
[...],
"icons": [
{
"src": "/assets/img/icons/rick-morty-pwa-icon-192x192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/assets/img/icons/rick-morty-pwa-icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
],
[...]
}
There you go. Your manifest has all the properties it needs for this project. However, it will not be copied to the dist
folder unless we add it to our Webpack configurations.
Open webpack.config.js
. The plugin responsible for copying static files is the CopyPlugin
. Add this line to the array:
{ from: 'src/manifest.json', to: 'manifest.json' },
Add meta and link tags
Open src/index.html
.
Below the last meta tag add these ones:
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Rick & Morty PWA" />
<meta name="description" content="PWA with Workbox" />
<meta name="theme-color" content="#004d40" />
Below the last link tag ad these ones:
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/assets/img/icons/rick-morty-pwa-icon-512x512.png" />
And it would also be very good to add this after your scripts:
<noscript>Please enable JavaScript to continue using this application.</noscript>
Verify changes with Lighthouse
Let´s do it again:
npm run build
npm run lighthouse
We can declare the PWA Optimized section resolved since the HTTPS flag does not represent a problem. In fact notice that in the Installable section we have been always getting the green color on “Uses HTTPS” since localhost is allowed as secure.
However, we still have 3 bugs to solve:
-
Current page does not respond with a 200 when offline.
-
start_url
does not respond with a 200 when offline. -
Does not register a service worker that controls page and
start_url
.
But don’t worry. Everything will get better when we implement our service worker.
If you didn’t make it
git checkout step-01-web-app-manifest
git checkout -b step-01-web-app-manifest-mine
Step 2: App Shell
Add the following code to your src/index.html
file, right after the script tag for app.js
:
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(
registration => {
console.log(`Service Worker registered! Scope: ${registration.scope}`);
},
error => {
console.error(`Service Worker registration failed: ${error}`);
},
);
});
}
</script>
Does it look familiar to you? We already talked about it in the previous article. It really does not matter if we include this snippet of code in a JavaScript file or directly in the HTML’s script tag. It is a question of personal taste and many people do it like this because it looks clear and separated from anything else.
npm run build
Take a look at the console. You should be looking at such an error:
That is expected. We need to create the service worker referenced in your index.html
.
Create the App Shell
One of the nicest things of Workbox version 5 is that it provides full Typescript support. So thinking of this premises you are going to create src/ts/sw.ts
:
import { precacheAndRoute } from 'workbox-precaching';
declare var self: WorkerGlobalScope & typeof globalThis;
precacheAndRoute(self.__WB_MANIFEST);
Do you remember when we talked in the previous article about JavaScript threads?
The typing definition for the self
global this
is supposed to be specified in node_modules/typescript/lib/lib.webworker.d.ts
. However there is an issue with this and therefore we need to re-declare that global variable in our file.
self.__WB_MANIFEST
is just a placeholder. Webpack will take that reference and generate our final dist/sw.js
. But for that we need to add a new plugin to our webpack.config.js
:
const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = {
[...],
plugins: [
[...],
new WorkboxPlugin.InjectManifest({
swSrc: './src/ts/sw.ts',
swDest: 'sw.js',
}),
],
};
Do that and build the app again.
npm run build
Now take a look at dist/sw.js
, As you can see the Workbox Webpack Plugin has taken care of including the code of the necessary Workbox libraries and moreover it has automatically created a service worker that pre-caches all our static files.
Tip: search in that file for this string:
workbox_precaching
and you will see it more clearly.
Verify changes
If you reload the page your console is probably looking much better now:
Now let’s run Lighthouse again.
npm run lighthouse
Another beautiful sight:
This is what a modern web app should look like!
If you didn’t make it
git checkout step-02-app-shell
git checkout -b step-02-app-shell-mine-mine
Step 3: Offline Experience
Now, Google Chrome caches many things without us having a service worker in place. You need to really check if your app shell is getting pre-cached by your implementation.
So first go offline again. Then in order to make sure that the browser completely loads the whole app again, you need to right-click on the browser’s reload button and then click on “Empty Cache and Hard Reload”.
Info: this option is only available when Chrome DevTools is open.
What do you see? It is the App Shell. We lost our dear offline dinosaur.
However wouldn’t it be even cooler if we saw the complete original content when we offline-reload the page? That is our goal.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
Cache API route
Go online again and reload the page.
Go to your DevTools Application tab and check on the Cache Storage section.
Look to the right. All our app shell, all the files specified in the dist/sw.js are cached there with their corresponding revision hash.
Now we need to cache the responses to the rickandmortyapi API.
The base URL we are using is https://rickandmortyapi.com/api/character
. And we have 3 different endpoints:
-
/?
gets all the characters. We use it on the home page. -
/${charaterId}
, e.g./1
, gets the character with id 1. It is used it on the character page. -
/avatar/${charaterId}.jpeg
, e.g./avatar/1.jpeg
gets the picture (or avatar) of the character with id 1. It is used it on both pages.
Checkout the 3 of them in your browser.
You are going to use Workbox registerRoute()
method to cache routes in runtime. In order to do that we need to use regular expressions.
The first needed regular expression matches retrieved data but not subsequent image requests. In other words: get all calls to the characters but not to their avatar images. Since new characters can die as the TV shows goes on, we need to have the most up-to-date information so we will use the above mentioned Network First
caching strategy.
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';
// import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'; // For later.
declare var self: WorkerGlobalScope & typeof globalThis;
precacheAndRoute(self.__WB_MANIFEST);
registerRoute(
/https:\/\/rickandmortyapi.com\/api\/character(?!\/avatar)/,
new NetworkFirst({
cacheName: 'rickandmortyapi-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 20,
}),
],
}),
);
You can replace the contents of your src/ts/sw.ts
with that.
The Workbox strategy can be provided with a custom cache name (recommended) and also plugins when needed. In this case you should only be interested in caching 20 entries so you should use the ExpirationPlugin
to set the cache expiration.
A new service worker
Now build the app again.
npm run build
What you are building is a new version of your service worker because more than one byte of the file has changed. The browser detects that automatically and assigns a new id number to it.
Go online again, reload the app and go to your DevTools Application tab again and see what has happened in the Service Workers section.
The service worker lifecycle ensures that the page is controlled by only one version of the service worker at a time. In this moment the old service worker with id #39529
is still active and the new one with id #39548
is waiting to be activated. We can activate the new service worker in different ways:
-
By closing al the windows (tabs) with the same origin (protocol + hostname + port) and then open again the app in a new one.
-
By clicking on skipWaiting.
-
By adding the
self.skipWaiting()
method to our service worker. -
By activating the “Update on reload” checkbox and then reloading the page.
The best practice is to go for Update on reload so please do that and reload the page.
Now the new service worker is active and we have a new cache slot.
If you implemented this route correctly you should see the cached response too:
And you couldn’t do better than taking a peek at the Network tab. You may find this interesting.
If there is a gear icon on the request it means that this is a request made by the service worker. The one without the gear icon is the served response which comes from the service worker and therefore from the Cache Storage.
Cache the images
But what happens if we go offline again and then reload the app with “Empty Cache and Hard Reload”? Well…
You have cached the response from the server but then some resource URLs are making extra calls to get the individual images. You are not caching that yet and that is why we can only see the pre-cached placeholder image on each of the characters.
You need a second regular expression that matches only the calls to avatar images. These are just avatars so we don’t need to constantly have the most up-to-date version of them. The StaleWhileRevalidate
strategy seems to fit our needs here.
registerRoute(
/https:\/\/rickandmortyapi\.com\/api\/character\/avatar\/(.+)\.(?:jpeg|jpg)/,
new StaleWhileRevalidate({
cacheName: 'avatar-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 20,
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
}),
],
}),
);
You can add that snippet to your src/ts/sw.ts
, too.
Please don’t forget to update your Typescript imports accordingly.
Additionally in this case we choose a maximum age for the cache: the request will never be cached for longer than a week.
npm run build
Then go online and reload the page.
Now your whole app should run perfectly offline!
If you get in trouble
If either the cache or the service workers behave funny and you have the need for a fresh start you can always call on a very useful utility from the DevTools: Application Clear Storage section and then click on “Clear site data”. This will not only remove the storage from this origin but it will also unregister all existing service workers.
Just remember that if you do that you will need to reload twice to see the runtime caches since on the first load you only get the pre-cached files. The rest of the information gets cached during the first life of the app so we will only be able to see it on a second round.
If you get in even more trouble
Even though this project takes a totally framework agnostic approach, this snippet coming from the Angular framework is very useful in extreme situations to really start fresh:
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
self.registration.unregister().then(() => {
console.log('NGSW Safety Worker - unregistered old service worker');
});
});
Just paste at the beginning of your dist/sw.js
file and reload the page.
Then you can build again:
npm run build
Of course you will also have to reload twice in this case.
If you didn’t make it
git checkout step-03-offline-experience
git checkout -b step-03-offline-experience-mine
Step 4: Install Experience
You could already install the app if you wanted. Google Chrome should show an install button in the Google omnibar, also known as the address bar.
But we can do significantly better than that.
Install Script
There is already an install button provided for you in src/index.html
. It carries both the install-btn
class and the hidden
class. As you can guess the latter will force the element not to be displayed.
You just need to create an script to handle the interaction with that button. Provide it in src/index.html
, right after the script that registers your service worker and before the <noscript>
tag.
<script src="/js/install.js" type="module"></script>
And make it real by creating src/ts/install.ts
. Add these contents to it:
import { BeforeInstallPromptEvent, UserChoice } from './models/before-install-promp';
const installButton: HTMLElement = document.querySelector('.install-btn');
let deferredInstallPrompt: BeforeInstallPromptEvent | null = null;
window.addEventListener('beforeinstallprompt', saveBeforeInstallPromptEvent);
installButton.addEventListener('click', installPWA);
function installPWA(event: Event): void {
const srcElement: HTMLElement = event.srcElement as HTMLElement;
// Add code show install prompt & hide the install button.
deferredInstallPrompt.prompt();
// Hide the install button, it can't be called twice.
srcElement.classList.add('hidden');
// Log user response to prompt.
deferredInstallPrompt.userChoice.then((choice: UserChoice) => {
if (choice.outcome === 'accepted') {
console.log('User accepted the install prompt', choice);
} else {
srcElement.classList.remove('hidden');
console.log('User dismissed the install prompt', choice);
}
deferredInstallPrompt = null;
});
}
function saveBeforeInstallPromptEvent(event: BeforeInstallPromptEvent): void {
// Add code to save event & show the install button.
deferredInstallPrompt = event;
installButton.classList.remove('hidden');
}
In this script there are 2 variables: one for the button element and another one for the beforeinstallprompt
event which we initialize to null
.
Additionally you need to listen to the click event on that button and apply the corresponding callback functions to both events.
The saveBeforeInstallPromptEvent
callback function receives beforeinstallprompt
as an event parameter and saves it in the deferredInstallPrompt
variable. It also makes the button visible by removing the hidden
class.
The installPWA
callback function prompts the banner, hides the button and depending on the user’s choice shows a different message in the console.
And last but not least. This new Typescript file needs to be transpiled by Webpack too so you need to add it to webpack.config.js
.
entry: {
app: './src/ts/app.ts',
install: './src/ts/install.ts',
},
Try it out
npm run build
And reload the page. You should see the install button.
Now click on install. Don’t be afraid. You should see the same as when you clicked on the Google Chrome install button before.
Reject the installation this time and take a look at the console.
And then do the same but this time accept the installation. You will be prompted with the web app in its own window and the console will still be opened. Take a look at the new message before closing the console.
The app should now be displayed among your Chrome Applications.
But most importantly it should be now installed in your system.
You can even create a desktop shortcut for it.
The install button may still be there. You should close and open the app from any of the 2 mentioned sources.
This is it
You did it! If you got here it means that you are already a PWA developer.
Congratulations!
And of course….
If you didn’t make it
git checkout step-04-install-experience
git checkout -b step-04-install-experience-mine
Until next time, friend
Here is where our journey ends for now. I hope you enjoyed it!
If you want you provide some feedback to this article please ping me on Twitter.
Or if you think there is something that can be improved please submit a pull request on GitHub.
Cheers!