Making a Simple PWA
Tags: webdev, pwa, frontend
Published: 2023-04-24 10:57:00

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:

```html
<!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()">&lt;</button>
			<span id="api_id"></span>
			<button onClick="next()">&gt;</button>
		</div>

		<pre id="result"></pre>
	</body>
</html>
```

`app.js` file:

```js
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:

```sh
python3 -m http.server --directory . 8080
```

Loading `http://localhost:8080` in your Web browser should show you this:

![The application UI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1huk7pmfr6to973rixt5.png)

This app will fetch characters from [https://swapi.dev/](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:

```json
{
	"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`:

```diff-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" />
+		<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:

![The application is now installable](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vbw0v3ic5t6to86h832i.png)

## 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:

```js
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:

```diff-js
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:

![The application loads while offline](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w9m0inflxa5md0gutvac.png)

Of course, the remote resources (from [https://swapi.dev/](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:

```diff
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.

```diff-js
/**
 * 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:

```js
/**
 * 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 🙂

![Offline mode demo](https://techreads.pipoprods.org/img/vw4CzP3wAa-720.gif)

## 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](https://code.pipoprods.org/web/simple-pwa)