NexusCS

Progressive Web Apps

JavaScript
A quick reference guide to building Progressive Web Apps (PWA) including manifest configuration, service worker lifecycle, caching strategies, and installability.
featured

Getting started

Introduction

Progressive Web Apps (PWAs) are web applications that use modern web capabilities to deliver an app-like experience. They work offline, can be installed on devices, and provide features like push notifications.

Basic PWA checklist

  • Web App Manifest (manifest.json)
  • Service Worker for offline support
  • HTTPS (required for service workers)
  • Icons (192x192 and 512x512 minimum)
  • Responsive design
  • Fast loading performance

Quick setup

<!DOCTYPE html>
<html>
  <head>
    <link rel="manifest" href="/manifest.json" />
    <meta name="theme-color" content="#3367D6" />
  </head>
  <body>
    <script>
      if ("serviceWorker" in navigator) {
        navigator.serviceWorker.register("/sw.js");
      }
    </script>
  </body>
</html>

Web App Manifest

Core properties

Property Type Description
name String Full app name (used when installed)
short_name String Short app name (home screen)
icons Array Image objects with src, sizes, type
start_url String URL to open when app launches
display String Display mode (see below)
background_color String Splash screen background
theme_color String Browser UI theme color
description String App description (max 300 chars)
scope String Navigation scope

Basic manifest.json

{
  "name": "Weather App",
  "short_name": "Weather",
  "start_url": "/?source=pwa",
  "display": "standalone",
  "background_color": "#3367D6",
  "theme_color": "#3367D6",
  "description": "Weather forecast information",
  "icons": [
    {
      "src": "/images/icons-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/images/icons-512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ]
}

Display modes

Mode Description Fallback
fullscreen Full screen without browser UI standalone
standalone Standalone window, no browser UI minimal-ui
minimal-ui Minimal browser controls browser
browser Standard browser tab N/A

Link manifest in HTML

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

Icon requirements

{
  "icons": [
    {
      "src": "/icon-192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/icon-512.png",
      "type": "image/png",
      "sizes": "512x512"
    },
    {
      "src": "/icon-maskable.png",
      "type": "image/png",
      "sizes": "512x512",
      "purpose": "any maskable"
    }
  ]
}

Purpose values: any, maskable, any maskable

Advanced properties

{
  "id": "com.example.weather",
  "orientation": "portrait",
  "screenshots": [
    {
      "src": "/screenshot1.png",
      "sizes": "1280x720",
      "type": "image/png"
    }
  ],
  "shortcuts": [
    {
      "name": "Today's Weather",
      "url": "/today",
      "icons": [{ "src": "/today-icon.png", "sizes": "96x96" }]
    }
  ]
}

Service Workers

Registration

if ("serviceWorker" in navigator) {
  navigator.serviceWorker
    .register("/sw.js", {
      scope: "/",
    })
    .then((reg) => {
      console.log("SW registered:", reg);
    })
    .catch((err) => {
      console.log("SW registration failed:", err);
    });
}

Lifecycle events

Event Description When It Fires
install First installation New SW downloaded
activate SW activated After install, no pages using old SW
fetch Network request intercepted Every resource request
message Message from client postMessage() called
push Push notification received Server sends push
sync Background sync Network available after offline

Install event (precaching)

const CACHE_NAME = "my-app-v1";
const urlsToCache = ["/", "/styles/main.css", "/script/main.js"];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache)),
  );
});

Activate event (cleanup)

self.addEventListener("activate", (event) => {
  const cacheWhitelist = ["my-app-v2"];

  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames.map((name) => {
          if (!cacheWhitelist.includes(name)) {
            return caches.delete(name);
          }
        }),
      ),
    ),
  );
});

Fetch event (basic)

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    }),
  );
});

Skip waiting

self.addEventListener("install", (event) => {
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(clients.claim());
});

Caching Strategies

Cache API methods

Method Description
caches.open(name) Open/create cache
cache.addAll(requests) Fetch and cache multiple
cache.put(request, response) Store request/response pair
cache.match(request) Find cached response
cache.delete(request) Remove cached response
cache.keys() Get all cached requests

Cache First

Best for static assets (CSS, JS, images).

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached;

      return fetch(event.request).then((response) => {
        return caches.open("v1").then((cache) => {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    }),
  );
});

Network First

Best for HTML pages and API requests.

self.addEventListener("fetch", (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        return caches.open("v1").then((cache) => {
          cache.put(event.request, response.clone());
          return response;
        });
      })
      .catch(() => caches.match(event.request)),
  );
});

Stale-While-Revalidate

Best for non-critical assets.

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.open("v1").then((cache) =>
      cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });

        return cached || fetchPromise;
      }),
    ),
  );
});

Cache with timeout

function timeout(ms, promise) {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error("timeout")), ms);
    promise.then(resolve, reject);
  });
}

self.addEventListener("fetch", (event) => {
  event.respondWith(
    timeout(5000, fetch(event.request)).catch(() =>
      caches.match(event.request),
    ),
  );
});

Network Only

self.addEventListener("fetch", (event) => {
  event.respondWith(fetch(event.request));
});

Cache Only

self.addEventListener("fetch", (event) => {
  event.respondWith(caches.match(event.request));
});

Workbox

Quick setup

importScripts(
  "https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js",
);

workbox.precaching.precacheAndRoute([
  { url: "/index.html", revision: "383676" },
  { url: "/styles/app.css", revision: "e28a9b" },
]);

workbox.routing.registerRoute(
  ({ request }) => request.destination === "image",
  new workbox.strategies.CacheFirst({
    cacheName: "images",
    plugins: [
      new workbox.expiration.ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60,
      }),
    ],
  }),
);

Workbox strategies

Strategy Class Use Case
Cache First CacheFirst() Static assets
Network First NetworkFirst() API, HTML
Stale-While-Revalidate StaleWhileRevalidate() Non-critical
Network Only NetworkOnly() Always fresh
Cache Only CacheOnly() Offline-only

Route matching

// By destination
workbox.routing.registerRoute(
  ({ request }) => request.destination === "image",
  new workbox.strategies.CacheFirst(),
);

// By URL pattern
workbox.routing.registerRoute(
  /\.(?:png|jpg|jpeg|svg|gif)$/,
  new workbox.strategies.CacheFirst(),
);

// By string match
workbox.routing.registerRoute("/api/", new workbox.strategies.NetworkFirst());

Expiration plugin

new workbox.strategies.CacheFirst({
  cacheName: "images",
  plugins: [
    new workbox.expiration.ExpirationPlugin({
      maxEntries: 60,
      maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      purgeOnQuotaError: true,
    }),
  ],
});

Background sync

const bgSyncPlugin = new workbox.backgroundSync.BackgroundSyncPlugin(
  "myQueueName",
  { maxRetentionTime: 24 * 60 },
);

workbox.routing.registerRoute(
  /\/api\/.*\/*.json/,
  new workbox.strategies.NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  "POST",
);

App Installation

beforeinstallprompt event

let deferredPrompt;

window.addEventListener("beforeinstallprompt", (e) => {
  // Prevent the mini-infobar from appearing
  e.preventDefault();

  // Stash the event for later use
  deferredPrompt = e;

  // Show custom install button
  showInstallButton();
});

Custom install button

installButton.addEventListener("click", async () => {
  if (!deferredPrompt) {
    return;
  }

  // Show the install prompt
  deferredPrompt.prompt();

  // Wait for the user's response
  const { outcome } = await deferredPrompt.userChoice;
  console.log(`User ${outcome} the install prompt`);

  // Clear the deferredPrompt
  deferredPrompt = null;
});

Detect if installed

function getPWADisplayMode() {
  const isStandalone = window.matchMedia("(display-mode: standalone)").matches;

  if (isStandalone) {
    return "standalone";
  }

  const isFullscreen = window.matchMedia("(display-mode: fullscreen)").matches;

  if (isFullscreen) {
    return "fullscreen";
  }

  return "browser";
}

if (getPWADisplayMode() === "standalone") {
  console.log("App is installed");
}

appinstalled event

window.addEventListener("appinstalled", () => {
  console.log("PWA was installed");

  // Hide install button
  hideInstallButton();

  // Track installation
  analytics.track("pwa_installed");
});

Check if installable

if ("BeforeInstallPromptEvent" in window) {
  console.log("App can be installed");
}

Push Notifications

Request permission

const permission = await Notification.requestPermission();

if (permission === "granted") {
  console.log("Notification permission granted");
} else {
  console.log("Notification permission denied");
}

Show notification from SW

self.registration.showNotification("Title", {
  body: "Body text",
  icon: "/icon.png",
  badge: "/badge.png",
  vibrate: [200, 100, 200],
  actions: [
    { action: "view", title: "View" },
    { action: "close", title: "Close" },
  ],
});

Notification options

Option Type Description
body String Notification body text
icon String Large icon URL
badge String Small icon for status bar
image String Large image URL
vibrate Array Vibration pattern [ms]
actions Array Action buttons
tag String Notification identifier
data Any Custom data
requireInteraction Boolean Don't auto-dismiss
silent Boolean No sound/vibration

Push event handler

self.addEventListener("push", (event) => {
  const data = event.data.json();

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon,
      data: { url: data.url },
    }),
  );
});

Notification click handler

self.addEventListener("notificationclick", (event) => {
  event.notification.close();

  event.waitUntil(clients.openWindow(event.notification.data.url));
});

Subscribe to push

const registration = await navigator.serviceWorker.ready;

const subscription = await registration.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});

// Send subscription to server
await fetch("/api/subscribe", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(subscription),
});

Get subscription

const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();

if (subscription) {
  console.log("Subscribed:", subscription);
} else {
  console.log("Not subscribed");
}

Background Sync

Register sync

const registration = await navigator.serviceWorker.ready;

await registration.sync.register("sync-messages");

Handle sync in SW

self.addEventListener("sync", (event) => {
  if (event.tag === "sync-messages") {
    event.waitUntil(sendMessagesToServer());
  }
});

async function sendMessagesToServer() {
  const messages = await getMessagesFromDB();

  for (const message of messages) {
    await fetch("/api/messages", {
      method: "POST",
      body: JSON.stringify(message),
    });
  }
}

Check sync support

if ("sync" in registration) {
  console.log("Background sync supported");
}

Get sync registrations

const tags = await registration.sync.getTags();
console.log("Active sync tags:", tags);

Meta Tags

Essential meta tags

<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#3367D6" />
<meta name="description" content="App description" />

iOS meta tags

<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="App Name" />
<link rel="apple-touch-icon" href="/icon-192.png" />

Status bar styles: default, black, black-translucent

Windows meta tags

<meta name="msapplication-TileImage" content="/icon-144.png" />
<meta name="msapplication-TileColor" content="#3367D6" />

All PWA meta tags

<!-- Viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1" />

<!-- Theme -->
<meta name="theme-color" content="#3367D6" />

<!-- iOS -->
<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="Weather App" />
<link rel="apple-touch-icon" href="/icon-192.png" />

<!-- Windows -->
<meta name="msapplication-TileImage" content="/icon-144.png" />
<meta name="msapplication-TileColor" content="#3367D6" />

<!-- Manifest -->
<link rel="manifest" href="/manifest.json" />

Vite PWA Plugin

Installation

npm install -D vite-plugin-pwa

Basic configuration

import { VitePWA } from "vite-plugin-pwa";

export default {
  plugins: [
    VitePWA({
      registerType: "autoUpdate",
      manifest: {
        name: "My App",
        short_name: "App",
        theme_color: "#ffffff",
        icons: [
          {
            src: "pwa-192x192.png",
            sizes: "192x192",
            type: "image/png",
          },
          {
            src: "pwa-512x512.png",
            sizes: "512x512",
            type: "image/png",
          },
        ],
      },
    }),
  ],
};

With Workbox

VitePWA({
  registerType: "autoUpdate",
  workbox: {
    globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
    runtimeCaching: [
      {
        urlPattern: /^https:\/\/api\.example\.com\/.*/i,
        handler: "NetworkFirst",
        options: {
          cacheName: "api-cache",
          expiration: {
            maxEntries: 10,
            maxAgeSeconds: 60 * 60, // 1 hour
          },
        },
      },
    ],
  },
});

Prompt for update

VitePWA({
  registerType: "prompt",
  manifest: {
    /* ... */
  },
});
// In your app
import { useRegisterSW } from "virtual:pwa-register/react";

function App() {
  const {
    needRefresh: [needRefresh, setNeedRefresh],
    updateServiceWorker,
  } = useRegisterSW();

  return (
    <>
      {needRefresh && (
        <button onClick={() => updateServiceWorker(true)}>
          Update available - Click to refresh
        </button>
      )}
    </>
  );
}

Debugging & Testing

Chrome DevTools

Application tab > Service Workers:

  • View registered service workers
  • Unregister service workers
  • Update on reload
  • Skip waiting
  • Offline mode

Application tab > Cache Storage:

  • View cached resources
  • Delete individual caches
  • Clear all caches

Lighthouse:

  • Run PWA audit
  • Check installability
  • Performance metrics

Offline testing

// In DevTools Console
navigator.serviceWorker.controller
  ? console.log("SW active")
  : console.log("No SW");

// Check cache
caches.keys().then(console.log);

// Check specific cache
caches.open("v1").then((cache) => cache.keys().then(console.log));

Force update SW

navigator.serviceWorker.getRegistration().then((reg) => reg.update());

Unregister SW

navigator.serviceWorker.getRegistration().then((reg) => reg.unregister());

Gotchas

  • HTTPS required: Service workers only work over HTTPS (localhost is exempt for development)
  • Cache API vs HTTP cache: Cache API is separate from browser HTTP cache headers
  • Service worker scope: SW can only control pages at or below its scope (default: location of SW file)
  • Icon requirements: Must provide at least 192x192 and 512x512 PNG icons for installability
  • Notification permission: Must be explicitly requested; can't be done in service worker context
  • SW updates: Browser checks for SW updates on navigation, but may take 24 hours to update
  • Cache invalidation: Must manually delete old caches in activate event
  • No DOM access: Service workers run in a separate thread with no access to DOM
  • Manifest scope: Navigation outside manifest scope opens in browser
  • iOS limitations: iOS Safari has limited PWA support (no push notifications until iOS 16.4)

Also see