Mastodon Skip to main content
techreads

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:

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

The application UI

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:

You should see an installation icon on your Web browser window:

The application is now installable

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:

The application loads while offline

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:

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 🙂

Offline mode demo

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:

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!