Making a Simple PWA
This article is the first of a series in which I'll cover Progressive Web Apps from scratch.
Nowadays, many tutorials show you how to make a PWA the simple way. IMO it's a bad thing that leads to most PWAs being nothing more than Web bookmarks and making users think this technology is useless.
In this article, I'll show how to make a PWA that:
- can be installed on your phone/computer as if it was a native application
- can be used both online and offline
Create a basic Web app #
The app will be made of two elements: an HTML page and a script.
index.html
file:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="mobile-web-app-capable" content="yes" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="theme-color" content="#8d2382" />
<title>Simple PWA</title>
<style type="text/css">
html,
body {
height: 100%;
width: 80%;
margin: 0;
display: flex;
flex-direction: column;
margin-left: auto;
margin-right: auto;
font-family: sans-serif;
}
h1,
div.controls {
text-align: center;
}
#api_id {
font-family: monospace;
text-align: center;
display: inline-block;
width: 5em;
}
pre {
display: flex;
margin-left: auto;
margin-right: auto;
}
</style>
<script type="text/javascript" src="app.js"></script>
</head>
<body>
<h1>Simple PWA</h1>
<div class="controls">
<button onClick="previous()"><</button>
<span id="api_id"></span>
<button onClick="next()">></button>
</div>
<pre id="result"></pre>
</body>
</html>
app.js
file:
document.addEventListener('DOMContentLoaded', () => {
fetch_data(1);
});
async function previous() {
let id = parseInt(document.getElementById('api_id').innerHTML, 10);
id--;
if (id <= 0) {
id = 0;
}
fetch_data(id);
}
async function next() {
let id = parseInt(document.getElementById('api_id').innerHTML, 10);
id++;
fetch_data(id);
}
async function fetch_data(id) {
document.getElementById('api_id').innerHTML = id;
fetch(`https://swapi.dev/api/people/${id}`).then((response) => {
if (response.ok) {
response.json().then((data) => {
document.getElementById('result').innerHTML = JSON.stringify(
data,
null,
2,
);
document.getElementById('api_id').focus();
});
} else {
document.getElementById('result').innerHTML = "You're currently offline";
document.getElementById('api_id').focus();
}
});
}
You can now serve your application through a Web server, for example using Python:
python3 -m http.server --directory . 8080
Loading http://localhost:8080
in your Web browser should show you this:
This app will fetch characters from https://swapi.dev/ and
display raw data in a pre
block.
Make it installable #
Important note: to be installable on a phone, a PWA must be served through HTTPS. Unless you have a properly configured Web server to deploy the app, you'll have to test your PWA on a computer Web browser (Chromium is advised as it provides a convenient "offline mode" toggle).
The first element to make your app installable is a manifest.
manifest.webmanifest
file:
{
"name": "Simple PWA",
"short_name": "Simple PWA",
"description": "A simple PWA, for testing",
"icons": [
{
"src": "icons/icon-32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"start_url": "/index.html",
"display": "fullscreen",
"theme_color": "#B12A34",
"background_color": "#B12A34"
}
The manifest must be registered in HTML file head
:
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="mobile-web-app-capable" content="yes" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="theme-color" content="#8d2382" />
+ <link rel="manifest" href="manifest.webmanifest" />
<title>Simple PWA</title>
You'll also need to place two icons in:
- icons/icon-32.png
- icons/icon-512.png
You should see an installation icon on your Web browser window:
Make it work offline #
Offline mode in PWAs rely on browser cache. You must store into cache all the
data that's needed by your application. In our case, the index.html
and
app.js
files are what we need to be cached.
This is done through a JavaScript code that will run in the background: the service worker.
sw.js
file:
const appCacheName = 'simple-pwa';
const appContentToCache = [
'/',
'index.html',
'app.js',
'favicon.ico',
'manifest.webmanifest',
'icons/icon-512.png',
];
/**
* First of all, the service worker will react to an 'install' event (triggered automatically)
* We'll put the application content into cache.
*/
self.addEventListener('install', (e) => {
e.waitUntil(async () => {
await caches.delete(appCacheName);
const cache = await caches.open(appCacheName);
await cache.addAll(appContentToCache);
});
});
/**
* Then, the service worker will be involved every time an HTTP request is made
*/
self.addEventListener('fetch', (e) => {
e.respondWith(fetch_resource(e.request));
});
/**
* fetch a resource:
* - if resource is in app cache, return in
* - if resource can be obtained from remote server, fetch it
* - otherwise return HTTP-408 response
*/
async function fetch_resource(resource) {
response = await get_from_cache(resource, appCacheName);
if (response) {
return response;
} else {
try {
response = await fetch(resource);
return response;
} catch (error) {
return new Response('offline', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
});
}
}
}
/**
* query cache for resource
*/
async function get_from_cache(resource, cacheName = appCacheName) {
try {
const cache = await caches.open(cacheName);
const response = await cache.match(resource);
return response;
} catch (error) {
return;
}
}
Don't forget to update app.js
file to register the service worker:
document.addEventListener('DOMContentLoaded', () => {
fetch_data(1);
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register('sw.js');
+ }
});
Your application should be able to reload even when you're offline now:
Of course, the remote resources (from https://swapi.dev/) are not accessible.
Make remote resources available while offline #
We'll update our service worker to keep remote resources in a cache so that we can access them while offline.
We add a new cache:
const appCacheName = 'simple-pwa';
const appContentToCache = ['/', 'index.html', 'app.js', 'favicon.ico', 'manifest.webmanifest', 'icons/icon-512.png'];
+const dataCacheName = `${appCacheName}-data`;
The we update the fetch_resource
logic that way:
- if resource is not in app cache, try to get it from remote
- if remote resource is not reachable, try to get it from data cache
- if not reachable, nor in cache, return an HTTP-408 code
This way we'll give the user the up-to-date data from remote server whenever it's possible and give him cached version when offline.
/**
* fetch a resource:
* - if resource is in app cache, return in
* - if resource can be obtained from remote server, fetch it
+ * - if resource is in data cache, return it
* - otherwise return HTTP-408 response
*/
async function fetch_resource(resource) {
response = await get_from_cache(resource, appCacheName);
if (response) {
return response;
} else {
try {
response = await fetch(resource);
+ await put_into_cache(resource, response);
return response;
} catch (error) {
+ response = await get_from_cache(resource);
+ if (response) {
+ // resource was found in data cache
+ return response;
+ } else {
return new Response('offline', {
status: 408,
headers: { 'Content-Type': 'text/plain' },
});
+ }
}
}
}
A new function is needed to put data in cache:
/**
* put resource into cache
*/
async function put_into_cache(request, response, cacheName = dataCacheName) {
const cache = await caches.open(cacheName);
await cache.put(request, response.clone());
}
With this updated application code, you'll be able to fetche data from remote API, go offline and still be able to display this data.
Your PWA now has a decent offline mode 🙂
Final thoughts #
This post shows that a PWA can be a good alternative to native or hybrid mobile apps in some cases. In a future post, we'll get deeper into it and see how:
- the frontend and the service worker can communicate and provide feedback to the user,
- the service worker can trigger frontend refresh to get application updates.
This post was intended to show how to make a PWA from scratch. Proper code architecture was not a consideration. The code presented here should be refactored to make it stronger.
The source code is here: https://code.pipoprods.org/web/simple-pwa
Let's discuss about this on Mastodon!