Progressive Web Apps 102: Building a Progressive Web App from Scratch

Progressive Web Apps (PWAs) have been gaining a lot of traction in recent years as a way to combine the best of the web and native apps. According to Google, PWAs have seen a 68% increase in mobile traffic and a 15% increase in conversions for e-commerce sites that have implemented them.[^1] [^1]: Google, "Progressive Web Apps," https://web.dev/what-are-pwas/

In this article, we‘ll dive deep into the process of building a PWA from scratch, covering everything from setting up the basic HTML and JavaScript to implementing advanced features like offline support, push notifications, and more.

What Makes a Progressive Web App?

But first, let‘s recap what exactly makes an app a PWA. At a minimum, a PWA must have:

  1. A web app manifest file (manifest.json)
  2. A service worker with at least a fetch event handler
  3. HTTPS (required for service workers and other PWA features)

Beyond these core requirements, PWAs can also include other features that bridge the gap between web and native apps, such as:

  • Offline support
  • Push notifications
  • Background sync
  • Native device feature access (camera, microphone, etc.)
  • Installability (add to home screen)
  • Smooth navigation and animations

PWAs are not a separate type of app, but rather a set of best practices and modern web technologies that can be progressively added to any web app to make it more app-like.

PWAs vs. Native Apps vs. Regular Web Apps

So how do PWAs compare to other types of apps? Here‘s a quick comparison table:

Feature PWA Native App Web App
Installation Yes (add to home screen) Yes (app store) No
Offline Support Yes Yes No
Push Notifications Yes Yes No (except via third-party service)
Native Device Access Yes (limited) Yes No
Cross-Platform Yes No (separate app per platform) Yes
Discoverability Yes (search engines, URLs) No (walled garden app stores) Yes
Updates Instant (via service worker) App store approval process Instant
Development Cost Lower (single codebase) Higher (native development per platform) Lower (web technologies)

As you can see, PWAs offer many of the benefits of native apps while retaining the advantages of the web, like cross-platform support, discoverability, and instant updates. They‘re not a complete replacement for native apps in all cases, but for many scenarios they can be a compelling alternative.

Building a PWA Step-by-Step

Now that we‘ve covered the background, let‘s walk through the process of actually building a PWA from the ground up. We‘ll create a simple app that displays a gallery of images fetched from an API.

Step 1: Setting Up the HTML and JavaScript

First, let‘s create a basic HTML structure for our app in index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My PWA</title>
    <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
    <header>

    </header>
    <main>
        <div id="gallery"></div>
    </main>

    <script src="/js/app.js"></script>
</body>
</html>

Next, let‘s add the JavaScript to fetch and display the images in app.js:

const apiUrl = ‘https://api.example.com/images‘;
const gallery = document.querySelector(‘#gallery‘);

async function fetchImages() {
  const res = await fetch(apiUrl);
  const json = await res.json();

  json.forEach(imageUrl => {
    const img = document.createElement(‘img‘);
    img.src = imageUrl;
    gallery.appendChild(img);  
  });
}

fetchImages();

And some basic styles in styles.css:

body {
  font-family: sans-serif;
  margin: 0;
  padding: 20px;
}

h1 {
  text-align: center;
}

#gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 
  grid-gap: 20px;
}

img {
  width: 100%;
  height: auto;
}

Step 2: Creating the Web App Manifest

The web app manifest is a JSON file that provides metadata about the app, such as its name, icons, start URL, and more. This allows the app to be installed on the user‘s device and provides the information needed to display it properly on the home screen or app launcher.

Create a manifest.json file in the root directory:

{
  "name": "Image Gallery PWA",
  "short_name": "Gallery",
  "icons": [
    {
      "src": "/img/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/img/icon-512.png",
      "sizes": "512x512", 
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#333333",
  "background_color": "#ffffff"
}

Link to the manifest from the <head> of your index.html:

<link rel="manifest" href="/manifest.json">

Tools like PWA Builder (https://www.pwabuilder.com/) can help generate the necessary icons and manifest file based on an existing website URL.

Step 3: Registering a Service Worker

Service workers are the key to PWAs‘ offline capabilities and other advanced features. They run separately from the main browser thread and can intercept network requests, cache resources, and deliver push messages.

First, register the service worker in app.js:

if (‘serviceWorker‘ in navigator) {
  window.addEventListener(‘load‘, () => {
    navigator.serviceWorker.register(‘/sw.js‘);
  });
}

Then create the service worker file sw.js:

const cacheName = ‘image-gallery-v1‘; 
const staticAssets = [
  ‘/‘,
  ‘/index.html‘,
  ‘/css/styles.css‘,
  ‘/js/app.js‘
];

self.addEventListener(‘install‘, async e => {
  const cache = await caches.open(cacheName);
  await cache.addAll(staticAssets);
  return self.skipWaiting();
});

self.addEventListener(‘activate‘, e => {
  self.clients.claim();
});

self.addEventListener(‘fetch‘, async e => {
  const req = e.request;
  const url = new URL(req.url);

  if (url.origin === location.origin) {
    e.respondWith(cacheFirst(req));
  } else {
    e.respondWith(networkFirst(req));
  }
});

async function cacheFirst(req) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(req);
  return cached || fetch(req);
}

async function networkFirst(req) {
  const cache = await caches.open(cacheName);
  try {
    const fresh = await fetch(req);
    await cache.put(req, fresh.clone());
    return fresh;
  } catch (e) {
    const cached = await cache.match(req);
    return cached;
  }
}

This service worker does a few key things:

  1. Caches the app shell (HTML, CSS, JS) on install
  2. Uses a cache-first strategy for local assets
  3. Uses a network-first strategy for remote assets (falling back to cache if offline)
  4. Allows new versions of the service worker to take control immediately via skipWaiting() and clients.claim()

Step 4: Enabling HTTPS

PWAs must be served over HTTPS due to security requirements for service workers and other features. On a production server this means configuring SSL with a certificate from a trusted certificate authority.

For local development, tools like ngrok (https://ngrok.com/) can provide a secure tunnel to your local server.

Advanced PWA Features

With the core components of a PWA in place, you can start adding more advanced features to enhance the user experience.

Web Push Notifications

PWAs can display push notifications just like native apps, even when the app is not open. This requires a combination of several technologies:

  1. Service worker to receive push events
  2. Push API to subscribe the user and trigger push messages from the server
  3. Notification API to display the notification to the user
  4. Backend server to send push messages via a push service

Here‘s a simplified example of how to request permission and subscribe a user to push notifications:

const publicVapidKey = ‘YOUR_PUBLIC_VAPID_KEY‘;

async function subscribePush() {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: publicVapidKey
  });

  await fetch(‘/subscribe‘, {
    method: ‘POST‘,
    body: JSON.stringify(subscription),
    headers: {
      ‘content-type‘: ‘application/json‘
    }
  });
}

And in the service worker:

self.addEventListener(‘push‘, event => {
  const data = event.data.json();
  self.registration.showNotification(data.title, {
    body: data.body,
    icon: data.icon,
    badge: data.badge
  });
});

This just scratches the surface of what‘s possible with web push – for a more complete guide, check out this tutorial: https://developers.google.com/web/ilt/pwa/introduction-to-push-notifications.

Background Sync

Background sync allows deferred actions like posting a comment or sending a message to be handled by the service worker even if the user is offline. The sync event is triggered as soon as the user has a stable connection again.

Here‘s a basic example:

async function submitComment(e) {
  e.preventDefault();

  const formData = new FormData(e.target); 
  const comment = formData.get(‘comment‘);

  try {
    await fetch(‘/comment‘, {
      method: ‘POST‘,
      body: JSON.stringify({ comment }),
      headers: { ‘Content-Type‘: ‘application/json‘ }
    });
  } catch (err) {
    await scheduleSyncComment(comment);
  }
}

async function scheduleSyncComment(comment) {
  const reg = await navigator.serviceWorker.ready;
  await reg.sync.register(‘submit-comment‘);
  localStorage.setItem(‘pending-comment‘, comment);
}

And in the service worker:

self.addEventListener(‘sync‘, event => {
  if (event.tag === ‘submit-comment‘) {
    event.waitUntil(
      submitPendingComment()
    );
  }
});

async function submitPendingComment() {
  const comment = localStorage.getItem(‘pending-comment‘);
  try {
    await fetch(‘/comment‘, {
      method: ‘POST‘,
      body: JSON.stringify({ comment }),
      headers: { ‘Content-Type‘: ‘application/json‘ }
    });
    localStorage.removeItem(‘pending-comment‘);
  } catch (err) {
    console.error(‘Error submitting comment:‘, err);
  }
}

This pattern of optimistic UI updates with background sync for eventual consistency is very powerful for building offline-capable apps.

For more on Background Sync, read: https://developers.google.com/web/updates/2015/12/background-sync.

Native Device Features

Modern web APIs allow PWAs to access many native device capabilities like the camera, microphone, GPS, accelerometer, and more. For example, here‘s how to access the device camera:

async function getCamera() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ video: true });
    const videoTracks = stream.getVideoTracks();
    const capabilities = videoTracks[0].getCapabilities();
    const settings = videoTracks[0].getSettings();

    // Use the stream 
  } catch (err) {
    console.error(‘Error accessing camera:‘, err);
  }
}

Of course, the user must grant explicit permission for the app to use these sensitive APIs. For a complete list of native device APIs available to web apps, check out: https://whatwebcando.today/.

PWA Development Tools and Libraries

Building a PWA from scratch is very doable, but there are also many tools and libraries that can help speed up development:

The Future of PWAs

PWAs are only going to get more capable and ubiquitous as web platform features continue to evolve. Some of the key improvements coming to PWAs in the near future include:

All of these capabilities further blur the line between what‘s possible on the web vs. native.

Conclusion

Progressive Web Apps represent a major shift in how we think about app development. By leveraging modern web capabilities, PWAs deliver native-like experiences with all the reach and accessibility of the web.

In this article, we‘ve covered the key components of a PWA and walked through the process of building one from the ground up. We‘ve also explored some of the more advanced features and capabilities that PWAs can incorporate, and looked ahead to the future of the web platform.

Whether you‘re a web developer looking to enhance your existing web apps, or a native developer considering a more cross-platform approach, PWAs are definitely worth adding to your toolkit. The web platform has come a long way, and with PWAs it‘s only getting more powerful.

Similar Posts